Compare commits

..

1 Commits

Author SHA1 Message Date
nik8
69dc2530ce Schrift in Kasten 2025-03-16 17:48:31 +01:00
132 changed files with 496 additions and 9200 deletions

4
.gitignore vendored
View File

@@ -1,10 +1,6 @@
__pycache__ __pycache__
.venv .venv
.env
db.sqlite3 db.sqlite3
tailwindcss.exe tailwindcss.exe
t-style.css t-style.css
dump.rdb dump.rdb
media
node_modules
staticfiles

View File

@@ -7,7 +7,6 @@
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
@@ -41,69 +40,23 @@ Um das Projekt erfolgreich zu starten, folge diesen Schritten:
``` ```
7. Die Datenbank migrieren: 7. Die Datenbank migrieren:
```sh ```sh
python django/manage.py migrate python3 core/manage.py migrate
``` ```
## Starten des Servers ## Starten des Servers
### Entwicklungsserver starten ### Entwicklungsserver starten
Für die Entwicklung mit WebSocket-Unterstützung verwenden wir Daphne: Um den Server zu starten, verwende einen der folgenden Befehle:
1. Aktiviere die virtuelle Umgebung: - Standardport (8000):
```sh ```sh
source .venv/bin/activate # Linux python3 core/manage.py runserver
.venv\Scripts\activate # Windows ```
``` - Bestimmten Port (z. B. 9000):
```sh
2. Starte den Daphne-Server: python3 core/manage.py runserver 9000
```sh ```
# Im Verzeichnis 'django'
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:
```sh
python3 core/manage.py runserver
```
### Produktionsserver starten
Für den Produktivbetrieb verwenden wir Daphne als ASGI-Server, der sowohl HTTP als auch WebSocket-Verbindungen unterstützt:
1. Aktiviere die virtuelle Umgebung wie oben beschrieben
2. Sammle die statischen Dateien:
```sh
python3 core/manage.py collectstatic
```
3. Starte den Daphne-Server:
```sh
daphne -b 0.0.0.0 -p 8000 core.asgi:application
```
- `-b 0.0.0.0`: Bindet den Server an alle Netzwerk-Interfaces
- `-p 8000`: Port (anpassbar)
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!
@@ -147,10 +100,10 @@ npx @tailwindcss/cli -i <INPUT-DATEI> -o <OUTPUT-DATEI> --watch --minify
```sh ```sh
# unter Linux mit Tailwind Binärdatei: # unter Linux mit Tailwind Binärdatei:
npx tailwindcss -i static/css/t-input.css -o static/css/t-style.css --watch --minify tailwindcss -i core/static/homepage/t-input.css -o core/static/homepage/t-style.css --watch --minify
# unter Windows mit NPM (in bash mit '/' statt '\'): # unter Windows mit NPM:
npx @tailwindcss/cli -i ./static/css/t-input.css -o ./static/css/t-style.css --watch --minify npx @tailwindcss/cli -i .\core\static\homepage\t-input.css -o .\core\static\homepage\t-style.css --watch --minify
``` ```
## Issue - Merge Request - Merge ## Issue - Merge Request - Merge
@@ -159,14 +112,6 @@ 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 Branch automatisch angelegt. Dieser kann mit `git pull` geholt und mit `git checkout <branch>` bearbeitet werden. 2. Merge Request anlegen - dadurch wird der Bransch 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

@@ -1,7 +0,0 @@
Name und Anschrift des Verantwortlichen
//NAME HIER EINSETZEN//
//ADRESSE HIER EINSETZEN//
//E-MAIL HIER EINSETZEN//

View File

@@ -6,15 +6,11 @@ from django import forms
class RegisterForm(UserCreationForm): class RegisterForm(UserCreationForm):
class Meta: class Meta:
model=User model=User
fields = ['username','password1','password2', "email"] fields = ['username','password1','password2']
username = forms.CharField( username = forms.CharField(
widget=forms.TextInput(attrs={'placeholder': 'BENUTZERNAME'}) widget=forms.TextInput(attrs={'placeholder': 'BENUTZERNAME'})
) )
email = forms.EmailField(
widget=forms.TextInput(attrs={'placeholder': 'E-MAIL'})
)
password1 = forms.CharField( password1 = forms.CharField(
widget=forms.PasswordInput(attrs={'placeholder': 'PASSWORT'}) widget=forms.PasswordInput(attrs={'placeholder': 'PASSWORT'})
) )
@@ -23,3 +19,4 @@ class RegisterForm(UserCreationForm):
) )

View File

@@ -1,8 +1,7 @@
from django.urls import path, reverse_lazy # type: ignore from django.urls import path # type: ignore
from . import views from . import views
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.contrib.auth import views as auth_views
app_name = 'accounts' # Namensraum zum verhindern von Konflikten zwischen Apps app_name = 'accounts' # Namensraum zum verhindern von Konflikten zwischen Apps
# #
@@ -14,22 +13,4 @@ urlpatterns = [
path('logout/', LogoutView.as_view(next_page='accounts:login'), name='logout'), path('logout/', LogoutView.as_view(next_page='accounts:login'), name='logout'),
path('login/', LoginView.as_view(template_name='accounts/login.html', next_page='accounts:home'), name='login'), path('login/', LoginView.as_view(template_name='accounts/login.html', next_page='accounts:home'), name='login'),
path('register/', views.register, name='register'), path('register/', views.register, name='register'),
path('password_reset/', auth_views.PasswordResetView.as_view(success_url=reverse_lazy('accounts:password_reset_done')), name='password_reset'),
path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
path('password_change/', views.password_changed, name='password_change'),
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),
path('delete_account/', views.deleteAccount, name="delete_account"),
path(
'reset/<uidb64>/<token>/',
auth_views.PasswordResetConfirmView.as_view(
template_name='registration/password_reset_confirm.html',
success_url=reverse_lazy('accounts:password_reset_complete')
),
name='password_reset_confirm'
),
] ]

View File

@@ -1,65 +1,19 @@
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth import login, authenticate
from .forms import RegisterForm from .forms import RegisterForm
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth import update_session_auth_hash
from django.contrib import messages
from django.contrib.auth import logout
from library.models import QivipQuiz, QivipQuestion
# Create your views here. # Create your views here.
@login_required @login_required
def home(request): def home(request):
my_quizzes_number=QivipQuiz.objects.filter(user_id=request.user).count() return render(request, 'accounts/home.html')
my_questions_number = QivipQuestion.objects.filter(quiz_id__user_id=request.user).count()
context = {
'my_quizzes_number': my_quizzes_number,
'my_questions_number': my_questions_number,
}
return render(request, 'accounts/home.html', context=context)
def register(response): def register(response):
if response.method == "POST": if response.method == "POST":
form = RegisterForm(response.POST) form = RegisterForm(response.POST)
if form.is_valid(): if form.is_valid():
user=form.save() form.save()
login(response, user) #automatischer Login nach Registrierung return redirect("home")
return redirect("accounts:home")
else: else:
form = RegisterForm() form = RegisterForm()
return render(response, "accounts/register.html", {"form":form}) return render(response, "accounts/register.html", {"form":form})
from django.contrib.auth import views as auth_views
@login_required
def password_changed(request):
if request.method == 'POST':
form = PasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid():
user = form.save()
#update_session_auth_hash(request, user) #Hier wäre der User nach Passwortänderung noch angemeldet!
logout(request)
messages.success(request, "Das Passwort wurde erfolgreich geändert!")
return redirect('accounts:home')
else:
form = PasswordChangeForm(user=request.user)
return render(request, 'registration/password_change_form.html', {'form': form})
@login_required
def deleteAccount(request):
if request.method == 'POST':
user=request.user
logout(request)
user.delete()
messages.success(request, "Der Account wurde erfolgreich gelöscht!")
return redirect('homepage:home')
return render(request, 'registration/delete_account_confirm.html')

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ComponentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'components'

View File

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
django/components/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path # type: ignore
from . import views
app_name = 'components' # Namensraum zum verhindern von Konflikten zwischen Apps
#
# Wichtig: Damit Links funktionieren müssen diese so eingebunden werden: {% url 'accounts:login' %} statt {% url 'login' %}
#
urlpatterns = [
path('<int:pk>', views.play, name='play'),
path('ranking/<int:pk>', views.show_rank, name='ranking')
]

View File

@@ -0,0 +1,9 @@
from django.shortcuts import render
# Create your views here.
def play(request,pk): #
return render(request, 'components/answer.html', {"pk": pk}) #
def show_rank(request,pk): #
return render(request, 'components/ranking.html', {"pk": pk}) #

View File

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

View File

@@ -8,21 +8,9 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
""" """
import os import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
from play.routing import websocket_urlpatterns application = get_asgi_application()
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(
websocket_urlpatterns
)
),
})

View File

@@ -10,42 +10,30 @@ 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, os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' #TODO: NUR FÜR ENTWICKLUNG
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# Support env variables from .env file if defined
env_path = load_dotenv(os.path.join(BASE_DIR, '.env'))
load_dotenv(env_path)
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
#SECRET_KEY = 'django-insecure-#+s307$rxkts=8@+_^i)8)n1b4*y4za1xz!mvv(h@!+n9!mp9)' SECRET_KEY = 'django-insecure-#+s307$rxkts=8@+_^i)8)n1b4*y4za1xz!mvv(h@!+n9!mp9)'
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-&psk#na5l=p3q8_a+-$4w1f^lt3lx1c@d*p4x$ymm_rn7pwb87')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
#DEBUG = False
#DEBUG = os.environ.get('DJANGO_DEBUG', '') != 'False'
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
INSTALLED_APPS = [ INSTALLED_APPS = [
'library.apps.LibraryConfig', 'library.apps.LibraryConfig',
'homepage.apps.HomepageConfig', 'homepage.apps.HomepageConfig',
'components.apps.ComponentsConfig',
'accounts.apps.AccountsConfig', 'accounts.apps.AccountsConfig',
'play', 'play',
'django.contrib.admin', 'django.contrib.admin',
@@ -54,14 +42,10 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'channels',
'django_cleanup',
'django.contrib.humanize',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@@ -89,17 +73,6 @@ TEMPLATES = [
] ]
WSGI_APPLICATION = 'core.wsgi.application' WSGI_APPLICATION = 'core.wsgi.application'
ASGI_APPLICATION = 'core.asgi.application'
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6380)],
},
},
}
# Database # Database
@@ -112,15 +85,6 @@ DATABASES = {
} }
} }
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
}
# Password validation # Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
@@ -157,32 +121,11 @@ USE_TZ = True
# https://docs.djangoproject.com/en/5.1/howto/static-files/ # https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / 'static'] STATICFILES_DIRS = [BASE_DIR / 'static']
# WhiteNoise configuration
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
WHITENOISE_SKIP_COMPRESS_EXTENSIONS = ['css', 'js']
# Return 404 instead of 500 for missing files
WHITENOISE_MISSING_FILE_ERRNO = None
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = '/account/login/' LOGIN_URL = '/account/login/'
import os
MEDIA_URL = '/media/' # URL, um auf Medien-Dateien zuzugreifen
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Speicherort für hochgeladene Dateien
# Email Settings
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'postoffice.katharineum.de'
EMAIL_PORT = 587 # 25
EMAIL_USE_TLS = True
EMAIL_HOST_USER = 'qivip-mail@katharineum.de'
EMAIL_HOST_PASSWORD = '<password>'
EMAIL_USE_SSL = False
DEFAULT_FROM_EMAIL = 'qivip-mail@katharineum.de'

View File

@@ -14,8 +14,6 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf.urls.static import static
from django.conf import settings
from django.contrib import admin # type: ignore from django.contrib import admin # type: ignore
from django.urls import path, include from django.urls import path, include
@@ -26,6 +24,3 @@ urlpatterns = [
path('play/', include('play.urls')), path('play/', include('play.urls')),
path('library/', include('library.urls')), path('library/', include('library.urls')),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -1,8 +1,3 @@
from django.contrib import admin from django.contrib import admin
from .models import QivipFaq
# Register your models here.
class QivipFaqAdmin(admin.ModelAdmin):
list_display = ('question','answer',)
# Registrierung der Modelle im Admin # Register your models here.
admin.site.register(QivipFaq, QivipFaqAdmin)

View File

@@ -1,22 +0,0 @@
# Generated by Django 5.1.7 on 2025-06-01 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='QivipFAQs',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', models.CharField(max_length=200)),
('answer', models.CharField(max_length=1000)),
],
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.1.7 on 2025-06-01 17:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('homepage', '0001_initial'),
]
operations = [
migrations.RenameModel(
old_name='QivipFAQs',
new_name='QivipFaq',
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.1.7 on 2025-06-01 17:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('homepage', '0002_rename_qivipfaqs_qivipfaq'),
]
operations = [
migrations.AlterField(
model_name='qivipfaq',
name='answer',
field=models.TextField(max_length=1000),
),
migrations.AlterField(
model_name='qivipfaq',
name='question',
field=models.TextField(max_length=200),
),
]

View File

@@ -1,6 +1,3 @@
from django.db import models from django.db import models
# Create your models here. # Create your models here.
class QivipFaq(models.Model):
question = models.TextField(max_length=200)
answer = models.TextField(max_length=1000)

View File

@@ -1,10 +1,7 @@
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from .models import QivipFaq
def home(request): def home(request):
faqs=QivipFaq.objects.all() return render(request, 'homepage/home.html')
context={"faqs": faqs}
return render(request, 'homepage/home.html', context)
def impress(request): def impress(request):
return render(request, 'homepage/impress.html') return render(request, 'homepage/impress.html')

View File

@@ -1,14 +1,15 @@
from django.contrib import admin from django.contrib import admin
from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizCategory, QuizImage, QuizRating from .models import QivipQuiz, QivipQuestion, QuizCategory, Tag
# Für das QivipQuiz Modell # Für das QivipQuiz Modell
class QivipQuizAdmin(admin.ModelAdmin): class QivipQuizAdmin(admin.ModelAdmin):
list_display = ('name','published_at', 'user_id', 'status', 'category', 'creation_date', 'update_date') # Welche Felder sollen in der Übersicht angezeigt werden list_display = ('name', 'user_id', 'status', 'category', 'creation_date', 'update_date') # Welche Felder sollen in der Übersicht angezeigt werden
search_fields = ('name', 'user_id__username', 'category__name') # Suchfelder search_fields = ('name', 'user_id__username', 'category__name') # Suchfelder
list_filter = ('status', 'category') # Filteroptionen list_filter = ('status', 'category') # Filteroptionen
# Für das QivipQuestion Modell # Für das QivipQuestion Modell
class QivipQuestionAdmin(admin.ModelAdmin): class QivipQuestionAdmin(admin.ModelAdmin):
list_display = ('id', 'quiz_id', 'data', 'creation_date', 'update_date', 'credits') list_display = ('quiz_id', 'data', 'creation_date', 'update_date')
search_fields = ('data',) search_fields = ('data',)
list_filter = ('quiz_id',) list_filter = ('quiz_id',)
@@ -18,47 +19,13 @@ 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):
class QivipQuizFavoriteAdmin(admin.ModelAdmin): list_display = ('name',) # Zeige nur den Namen des Tags
list_display = ('user', 'favorite', 'quiz') # Welche Felder sollen in der Übersicht angezeigt werden search_fields = ('name',)
class QuizImageAdmin(admin.ModelAdmin):
list_display = ('image',)
class QuestionImageAdmin(admin.ModelAdmin):
list_display = ('image',)
from django import forms
from .models import QuizCategory
class QuizCategoryForm(forms.ModelForm):
color = forms.CharField(
widget=forms.TextInput(attrs={'type': 'color'})
)
class Meta:
model = QuizCategory
fields = '__all__'
from django.utils.html import format_html
@admin.register(QuizCategory)
class QuizCategoryAdmin(admin.ModelAdmin):
form = QuizCategoryForm
list_display = ('name', 'color_preview', 'color')
def color_preview(self, obj):
return format_html(
'<div style="width: 24px; height: 24px; background-color: {}; border: 2px solid #000;"></div>',
obj.color
)
color_preview.short_description = "Farbe"
# 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(Tag, TagAdmin)
admin.site.register(QuizImage, QuizImageAdmin)
admin.site.register(QuestionImage, QuestionImageAdmin)

View File

@@ -1,46 +1,12 @@
from django import forms from django import forms
from .models import QivipQuiz, QivipQuestion, QuizCategory from .models import QivipQuiz, QivipQuestion
class QuizForm(forms.ModelForm): class QuizForm(forms.ModelForm):
class Meta: class Meta:
model = QivipQuiz model = QivipQuiz
fields = ['name', 'description', 'status', 'category','difficulty','credits'] fields = ['name', 'description', 'status', 'category', 'tags',"difficulty"]
def __init__(self, *args, **kwargs):
super(QuizForm, self).__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs.update({
'class': 'dark:bg-[#2a2f3a]! dark:text-white'
})
class QuestionForm(forms.ModelForm): class QuestionForm(forms.ModelForm):
class Meta: class Meta:
model = QivipQuestion model = QivipQuestion
fields = ['quiz_id', 'data','time_per_question'] fields = ['quiz_id', 'data']
from django import forms
class QuizFilterForm(forms.Form):
search = forms.CharField(required=False, label="Suche", widget=forms.TextInput(attrs={
'class': 'flex flex-grow rounded-lg p-2' ,'placeholder': 'Suche ...', 'list': 'quiz-names',
}))
min_amount_questions = forms.IntegerField(required=False, min_value=1, label="Minimale Anzahl an Fragen", widget=forms.NumberInput(attrs={
'class': 'border-2 border-gray-300 rounded-lg p-2 w-full'
}))
max_amount_questions = forms.IntegerField(required=False, min_value=1, label="Maximale Anzahl an Fragen", widget=forms.NumberInput(attrs={
'class': 'border-2 border-gray-300 rounded-lg p-2 w-full'
}))
user = forms.CharField(required=False, label="von User", widget=forms.TextInput(attrs={
'class': 'border-2 border-gray-300 rounded-lg p-2 w-full'
}))
categories = forms.ModelChoiceField(
queryset=QuizCategory.objects.filter().distinct(),
required=False,
label="Kategorie",
widget=forms.Select(attrs={
'class': 'border-2 border-gray-300 rounded-lg p-2 w-full dark:bg-[#2a2f3a] dark:text-white'
}))

View File

@@ -1,13 +0,0 @@
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

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-21 17:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0022_alter_qivipquiz_description_alter_qivipquiz_name'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='quiz_images/'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 10:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0023_qivipquiz_image'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-03 16:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0024_alter_qivipquiz_image'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='modified_description',
field=models.TextField(blank=True, default='Das Quiz wurde modifiziert von:', editable=False),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-04 13:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0025_qivipquiz_modified_description'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='modified_description',
field=models.TextField(blank=True, editable=False),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-04 13:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0026_alter_qivipquiz_modified_description'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='creator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='creator_quiz', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-04 13:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0027_qivipquiz_creator'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='creator',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='creator_quiz', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-04 16:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0028_alter_qivipquiz_creator'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='credits',
field=models.TextField(blank=True),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 09:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0029_qivipquiz_credits'),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='creator',
),
migrations.RemoveField(
model_name='qivipquiz',
name='modified_description',
),
migrations.AddField(
model_name='qivipquiz',
name='base_quiz',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_quizzes', to='library.qivipquiz'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 17:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0030_remove_qivipquiz_creator_and_more'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='quiz_id',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='library.qivipquiz'),
),
]

View File

@@ -1,37 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 19:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0031_alter_qivipquestion_quiz_id'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='average_rating',
field=models.FloatField(default=3.0),
),
migrations.AddField(
model_name='qivipquiz',
name='rating_count',
field=models.IntegerField(default=0),
),
migrations.CreateModel(
name='QuizRating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('participant_id', models.CharField(max_length=200)),
('rating', models.IntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')])),
('created_at', models.DateTimeField(auto_now_add=True)),
('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='library.qivipquiz')),
],
options={
'unique_together': {('quiz', 'participant_id')},
},
),
]

View File

@@ -1,18 +0,0 @@
# 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

@@ -1,20 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-10 13:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0032_qivipquiz_average_rating_qivipquiz_rating_count_and_more'),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='tags',
),
migrations.DeleteModel(
name='Tag',
),
]

View File

@@ -1,18 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-10 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0033_remove_qivipquiz_tags_delete_tag'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='status',
field=models.CharField(choices=[('öffentlich', 'Öffentlich'), ('privat', 'Privat')], default='öffentlich', max_length=10),
),
]

View File

@@ -1,18 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,32 +0,0 @@
# 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

@@ -1,19 +0,0 @@
# 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

@@ -1,19 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,19 +0,0 @@
# 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

@@ -1,34 +0,0 @@
# 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

@@ -1,19 +0,0 @@
# 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

@@ -1,17 +0,0 @@
# 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

@@ -1,22 +0,0 @@
# 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

@@ -1,22 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,14 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,38 +0,0 @@
# 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

@@ -1,24 +0,0 @@
# 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

@@ -1,29 +0,0 @@
# 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

@@ -1,38 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-06-13 16:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0053_questionimage_quizimage_alter_qivipquestion_image_and_more'),
]
operations = [
migrations.AddField(
model_name='qivipquestion',
name='credits',
field=models.TextField(blank=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-06-26 16:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0054_qivipquestion_credits'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='published_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.1.7 on 2025-06-27 12:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0055_qivipquiz_published_at'),
]
operations = [
migrations.AlterUniqueTogether(
name='quizrating',
unique_together=set(),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-07-02 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0056_alter_quizrating_unique_together'),
]
operations = [
migrations.AddField(
model_name='quizcategory',
name='color',
field=models.CharField(default='#000000', help_text='Farbe als Hex-Code, z.B. #ff0000', max_length=7),
),
]

View File

@@ -3,20 +3,11 @@ from django.contrib.auth.models import User
from django.utils.text import slugify from django.utils.text import slugify
import uuid import uuid
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils import timezone
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",
#"versteckt": "Versteckt", "versteckt": "Versteckt",
"privat": "Privat", "privat": "Privat",
} }
DIFFICULTY_VALUES = { DIFFICULTY_VALUES = {
@@ -29,131 +20,47 @@ class QivipQuiz(models.Model):
} }
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.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')
base_quiz = models.ForeignKey('self', on_delete=models.SET_NULL,blank=True, null=True, related_name='child_quizzes')
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',verbose_name="Kategorie", related_name='quiz', on_delete=models.CASCADE) category = models.ForeignKey('QuizCategory', related_name='quiz', on_delete=models.CASCADE)
name = models.CharField(max_length=75,verbose_name="Name des Quizzes") tags = models.ManyToManyField('Tag', blank=True)
description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...", verbose_name="Beschreibung") name = models.CharField(max_length=75)
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") description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...")
credits=models.TextField(blank=True, editable=True) difficulty= models.CharField(max_length=13, choices=list(DIFFICULTY_VALUES.items()), default="nicht gesetzt", help_text="1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit")
average_rating = models.FloatField(default=3.0)
rating_count = models.IntegerField(default=0)
published_at = models.DateTimeField(null=True, blank=True) # manuell gesetzt, wenn veröffentlicht
def is_published(self):
return self.published_at is not None and self.published_at <= timezone.now()
def __str__(self): def __str__(self):
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='question')
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"
)
credits=models.TextField(blank=True, editable=True)
def __str__(self): def __str__(self):
return self.data[:50] return self.data[:50]
class Tag(models.Model):
name = models.CharField(max_length=100, unique=True)
def __str__(self):
class QuizRating(models.Model): return self.name
quiz = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='ratings')
participant_id = models.CharField(max_length=200)
rating = models.IntegerField(choices=[(i, str(i)) for i in range(1, 6)])
created_at = models.DateTimeField(auto_now_add=True)
#class Meta:
#unique_together = ('quiz', 'participant_id')
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
# Update quiz average rating (immer, auch bei Updates)
quiz = self.quiz
total_ratings = quiz.ratings.count()
avg_rating = quiz.ratings.aggregate(models.Avg('rating'))['rating__avg']
quiz.average_rating = round(avg_rating, 1) if avg_rating else 3.0
quiz.rating_count = total_ratings
quiz.save()
"""
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new:
# Update quiz average rating
quiz = self.quiz
total_ratings = quiz.ratings.count()
avg_rating = quiz.ratings.aggregate(models.Avg('rating'))['rating__avg']
quiz.average_rating = round(avg_rating, 1) if avg_rating else 3.0
quiz.rating_count = total_ratings
quiz.save()"""
class QuizCategory(models.Model): class QuizCategory(models.Model):
name = models.CharField(max_length=100, unique=True) name = models.CharField(max_length=100, unique=True)
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
slug = models.SlugField(unique=True, blank=True) slug = models.SlugField(unique=True, blank=True)
color= models.CharField(
max_length=7,
default="#000000",
help_text="Farbe als Hex-Code, z.B. #ff0000"
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.slug: if not self.slug:

View File

@@ -1,15 +1,10 @@
from django.urls import path from django.urls import path
from django.conf import settings
from . import views from . import views
from django.conf.urls.static import static
app_name = 'library' app_name = 'library'
urlpatterns = [ urlpatterns = [
path('', views.overview_quiz, name='overview_quiz'), path('', views.overview_quiz, name='overview_quiz'),
path('my/', views.overview_quiz_my, name='overview_quiz_my'),
path('favorites/', views.overview_quiz_favorites, name='overview_quiz_favorites'),
path('new/', views.new_quiz, name='new_quiz'), path('new/', views.new_quiz, name='new_quiz'),
path('edit/<int:pk>/', views.edit_quiz, name='edit_quiz'), path('edit/<int:pk>/', views.edit_quiz, name='edit_quiz'),
path('delete/<int:pk>/', views.delete_quiz, name='delete_quiz'), path('delete/<int:pk>/', views.delete_quiz, name='delete_quiz'),
@@ -17,8 +12,4 @@ urlpatterns = [
path('question/new/', views.new_question, name='new_question'), path('question/new/', views.new_question, name='new_question'),
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('favorite_quiz/<int:pk>/', views.favorite_quiz, name='favorite_quiz'),
path("quiz-names-json/", views.quiz_names_json, name="quiz_names_json"),
] ]

View File

@@ -1,381 +1,35 @@
from django.utils import timezone
import uuid
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
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, QivipQuizFavorite, QuestionImage, QuizImage from .models import QivipQuiz, QivipQuestion
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
from .models import QivipQuiz
from .forms import QuizFilterForm
from django.db.models import Count
from django.contrib.auth.models import User
from urllib.parse import urlparse, urlunparse
# Übersicht aller Quizze # Übersicht aller Quizze
def quiz_names_json(request):
form = QuizFilterForm(request.GET)
if form.is_valid():
search = form.cleaned_data.get('search')
names = QivipQuiz.objects.all().annotate(max_amount_questions=Count('questions')).filter(max_amount_questions__gte=1, status='öffentlich', name__icontains=search)
if request.user.is_authenticated:
names = names | QivipQuiz.objects.filter(
user_id=request.user.id,
name__icontains=search
)
names=list(names.values_list('name', flat=True).distinct())
return JsonResponse(names, safe=False)
def apply_quiz_filters(queryset, form):
if not form.is_valid():
return queryset
queryset = queryset.annotate(question_count=Count('questions'))
search = form.cleaned_data.get('search')
username = form.cleaned_data.get('user')
min_questions = form.cleaned_data.get('min_amount_questions')
max_questions = form.cleaned_data.get('max_amount_questions')
category = form.cleaned_data.get('categories')
filters = {}
if search:
filters['name__icontains'] = search
if min_questions is not None:
filters['question_count__gte'] = min_questions
if max_questions is not None:
filters['question_count__lte'] = max_questions
if category is not None:
filters['category'] = category
if username:
try:
user = User.objects.get(username=username)
filters['user_id'] = user.id
except User.DoesNotExist:
return queryset.none()
return queryset.filter(**filters)
def overview_quiz(request, connect="all"):
form = QuizFilterForm(request.GET)
user = request.user if request.user.is_authenticated else None
# Meine Quizzeze mit mind. 1 Frage
filter_my_quizzes = QivipQuiz.objects.none()
if user:
filter_my_quizzes = QivipQuiz.objects.annotate(max_amount_questions=Count('questions')).filter(
user_id=user.id,
max_amount_questions__gte=0
)
# Öffentliche Alle mit mind. 1 Frage
filter_all_quizzes = QivipQuiz.objects.annotate(max_amount_questions=Count('questions')).filter(
status='öffentlich',
max_amount_questions__gte=1
)
if user:
filter_all_quizzes = filter_all_quizzes.exclude(user_id=user.id)
# Favoriten mit mind. 1 Frage
favorite_quiz_ids = []
favorite_quizzes = QivipQuiz.objects.none()
if user:
favorite_quiz_ids = QivipQuizFavorite.objects.filter(user=user, favorite=True).values_list('quiz', flat=True)
favorite_quizzes = QivipQuiz.objects.annotate(max_amount_questions=Count('questions')).filter(
id__in=favorite_quiz_ids,
max_amount_questions__gte=1
)
# Suche anwenden, falls vorhanden
filter_my_quizzes = apply_quiz_filters(filter_my_quizzes, form)
filter_all_quizzes = apply_quiz_filters(filter_all_quizzes, form)
favorite_quizzes = apply_quiz_filters(favorite_quizzes, form)
if request.user.is_authenticated:
filter_all_quizzes = apply_quiz_filters(filter_all_quizzes, form) | apply_quiz_filters(filter_my_quizzes, form)
# Treffer zählen
total_my = filter_my_quizzes.count()
total_all = filter_all_quizzes.count()
total_favorite = favorite_quizzes.count()
total_all_found=total_my + total_all + total_favorite
# Paginierung
paginator_my = Paginator(filter_my_quizzes.order_by('-creation_date'), 8)
paginator_all = Paginator(filter_all_quizzes.order_by('-published_at'), 8)
paginator_fav = Paginator(favorite_quizzes.order_by('-published_at'), 8)
my_page = request.GET.get('my_page', 1)
all_page = request.GET.get('all_page', 1)
fav_page = request.GET.get('favorite_page', 1)
try:
my_quizzes = paginator_my.page(my_page)
except:
my_quizzes = paginator_my.page(1)
try:
all_quizzes = paginator_all.page(all_page)
except:
all_quizzes = paginator_all.page(1)
try:
favorite_quizzes = paginator_fav.page(fav_page)
except:
favorite_quizzes = paginator_fav.page(1)
context = {
'form': form,
'my_quizzes': my_quizzes,
'all_quizzes': all_quizzes,
'favorite_quizzes': favorite_quizzes,
'total_my': total_my,
'total_all': total_all,
'total_favorite': total_favorite,
'total_all_found':total_all_found,
'show_search': True,
'favorite_quiz_ids': list(favorite_quiz_ids),
'url_my': '/library/my/',
'url_all': '/library/',
'url_favorite': '/library/favorites/',
}
query_params = request.GET.copy()
querydict_my = query_params.copy()
querydict_all = query_params.copy()
querydict_fav = query_params.copy()
# Entferne die jeweiligen Seiten-Parameter, damit wir sie neu setzen können
querydict_my.pop('my_page', None)
querydict_all.pop('all_page', None)
querydict_fav.pop('favorite_page', None)
# Füge die "bereinigten" Querystrings dem context hinzu
context.update({
'querystring_my': querydict_my.urlencode(),
'querystring_all': querydict_all.urlencode(),
'querystring_favorite': querydict_fav.urlencode(),
})
if connect=="all":
context.update({"link":"library:overview_quiz"})
return render(request, 'library/overview_quiz.html', context)
elif connect=="my":
context.update({"link":"library:overview_quiz_my"})
return render(request, 'library/overview_quiz_my.html', context)
elif connect=="favorite":
context.update({"link":"library:overview_quiz_favorites"})
return render(request, 'library/overview_quiz_favorite.html', context)
def overview_quiz_my(request):
return overview_quiz(request, connect="my")
def overview_quiz_favorites(request):
return overview_quiz(request, connect="favorite")
"""
def overview_quiz(request): def overview_quiz(request):
# Filter
form = QuizFilterForm(request.GET)
try: try:
favorite_quizzes_data = QivipQuizFavorite.objects.filter(user=request.user, favorite=True).order_by("-favorite_date") quizzes = QivipQuiz.objects.filter(user_id=request.user)
favorite_quiz_ids = favorite_quizzes_data.values_list('quiz', flat=True) all_quizzes = QivipQuiz.objects.filter(status='öffentlich').exclude(user_id=request.user).filter(question__isnull=False).distinct()
favorite_quizzes = QivipQuiz.objects.filter(id__in=favorite_quiz_ids) return render(request, 'library/overview_quiz.html', {'quizzes': quizzes, 'all_quizzes': all_quizzes})
except: except:
favorite_quizzes_data = QivipQuizFavorite.objects.none() all_quizzes = QivipQuiz.objects.filter(status='öffentlich').filter(question__isnull=False).distinct()
favorite_quizzes = QivipQuizFavorite.objects.none() return render(request, 'library/overview_quiz.html', {'quizzes': None, 'all_quizzes': all_quizzes})
favorite_quiz_ids = favorite_quizzes.none()
favorite_quizzes = QivipQuiz.objects.none()
# Initialize querysets
if request.user.is_authenticated:
filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amount_questions=Count('questions'))
favorite_quizzes = favorite_quizzes.filter(id__in=favorite_quiz_ids).annotate(max_amount_questions=Count('questions'))
filter_all_quizzes = QivipQuiz.objects.exclude(user_id=request.user)
else:
favorite_quizzes = QivipQuiz.objects.none()
filter_my_quizzes = QivipQuiz.objects.none()
filter_all_quizzes = QivipQuiz.objects.all()
# Apply common filters to all quizzes
filter_all_quizzes = filter_all_quizzes.annotate(max_amount_questions=Count('questions'))
filter_all_quizzes = filter_all_quizzes.filter(max_amount_questions__gte=1, status='öffentlich')
if form.is_valid():
search = form.cleaned_data.get('search')
username= form.cleaned_data.get('user')
max_amount_questions= form.cleaned_data.get('max_amount_questions')
min_amount_questions= form.cleaned_data.get('min_amount_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_all_quizzes=QivipQuiz.objects.none()
if search:
filters['name__icontains'] = search
if min_amount_questions:
filters['max_amount_questions__gte'] = min_amount_questions
if max_amount_questions:
filters['max_amount_questions__lte'] = max_amount_questions
try:
filter_all_quizzes = filter_all_quizzes.filter(**filters)
except:
filter_all_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()
# Pagination for my quizzes
my_quizzes_paginator = Paginator(filter_my_quizzes.order_by('-creation_date'), 8) # 8 quizzes per page
try:
my_quizzes = my_quizzes_paginator.page(request.GET.get('my_page', 1))
except:
my_quizzes = my_quizzes_paginator.page(1)
# Pagination for all quizzes
all_quizzes_paginator = Paginator(filter_all_quizzes.order_by('-creation_date'), 8) # 8 quizzes per page
try:
all_quizzes = all_quizzes_paginator.page(request.GET.get('all_page', 1))
except:
all_quizzes = all_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) # 9 quizzes per page
try:
favorite_quizzes = favorite_quizzes_paginator.page(request.GET.get('favorite_page', 1))
except:
favorite_quizzes = favorite_quizzes_paginator.page(1)
context = {
'show_search': True,
'favorite_quiz_ids': favorite_quiz_ids,
'favorite_quizzes':favorite_quizzes,
'my_quizzes': my_quizzes,
'all_quizzes': all_quizzes,
'form': form,
}
return render(request, 'library/overview_quiz.html', context)
"""
@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()
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:
favorite=QivipQuizFavorite.objects.create(user=request.user, quiz=quiz, favorite=True, favorite_date=timezone.now())
statement="Das Quiz '"+ favorite.quiz.name+ "' wurde erfolgreich zu den Favoriten hinzugefügt!"
messages.success(request,statement )
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)
form = QuizForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
quiz = form.save(commit=False) quiz = form.save(commit=False)
status = form.cleaned_data.get('status')
if status == 'öffentlich' and not quiz.published_at:
quiz.published_at = timezone.now()
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.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:edit_quiz', pk=quiz.pk)
else: else:
form = QuizForm() form = QuizForm()
return render(request, 'library/form.html', {'form': form}) return render(request, 'library/form.html', {'form': form})
@@ -385,43 +39,10 @@ def new_quiz(request):
def edit_quiz(request, pk): def edit_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':
form = QuizForm(request.POST, instance=quiz, files=request.FILES) form = QuizForm(request.POST, instance=quiz)
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
quiz = form.save(commit=False)
# Beispiel: Status aus dem Formular holen
status = form.cleaned_data.get('status')
if status == 'öffentlich' and not quiz.published_at:
quiz.published_at = timezone.now()
form.save() form.save()
for img in QuizImage.objects.all(): return redirect('library:overview_quiz')
try:
img.delete()
img.image.delete(save=False)
except:
pass
return redirect('library:detail_quiz', pk=pk)
#return modified(request, pk)
else: else:
form = QuizForm(instance=quiz) form = QuizForm(instance=quiz)
return render(request, 'library/form.html', {'form': form}) return render(request, 'library/form.html', {'form': form})
@@ -432,27 +53,15 @@ 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})
# Quiz anzeigen # Quiz anzeigen
@login_required
def detail_quiz(request, pk): def detail_quiz(request, pk):
quiz = get_object_or_404(QivipQuiz, pk=pk) quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
show_answers = request.GET.get('show_answers', 'false').lower() == 'true'
questions = QivipQuestion.objects.filter(quiz_id=quiz) questions = QivipQuestion.objects.filter(quiz_id=quiz)
# Parse JSON data for each question # Parse JSON data for each question
for question in questions: for question in questions:
if question.data: if question.data:
@@ -460,54 +69,9 @@ def detail_quiz(request, pk):
context = { context = {
'quiz': quiz, 'quiz': quiz,
'questions': questions, 'questions': questions
'detail':show_answers,
} }
if quiz.status!="privat" or quiz.user_id==request.user: return render(request, 'library/detail_quiz.html', context)
return render(request, 'library/detail_quiz.html', context)
else:
return redirect('library:overview_quiz')
@login_required
def copy_quiz(request, 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)
new_quiz = original_quiz # Kopie erstellen (aber Achtung: Noch gleiche Referenz!)
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.user_id = request.user # Neuer Besitzer ist der aktuelle User
new_quiz.name += " - Kopie"
new_quiz.base_quiz_id = pk
new_quiz.rating_count = 0
new_quiz.average_rating = 3.0
if original_quiz.status=="öffentlich":
new_quiz.published_at = timezone.now()
else:
new_quiz.published_at = None
new_quiz.save() # Speichern als neues Objekt
# Optional: Beschreibung anpassen
# Alle zugehörigen Fragen kopieren
questions = QivipQuestion.objects.filter(quiz_id=pk)
for question in questions:
question.uuid = uuid.uuid4()
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.data = question.data # `data`-Feld wird explizit übernommen # Verknüpfe mit dem neuen Quiz
question.save()
messages.success(request, "Quiz wurde erfolgreich kopiert!")
return redirect('library:detail_quiz', pk=new_quiz.pk)
# Übersicht aller Fragen # Übersicht aller Fragen
@login_required @login_required
@@ -537,7 +101,7 @@ def new_question(request):
messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.') messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.')
return redirect('library:overview_quiz') return redirect('library:overview_quiz')
if question_type not in ['multiple_choice', 'true_false','input']: 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}')
@@ -555,17 +119,6 @@ def new_question(request):
{'value': 'Falsch', 'is_correct': not correct_answer} {'value': 'Falsch', 'is_correct': not correct_answer}
] ]
} }
elif question_type == 'input':
correct_answer = request.POST.get('correct_answer')
json_data = {
'type': question_type,
'question': question_text,
'options': [
{'value': correct_answer, 'is_correct': True}
]
}
elif question_type == 'multiple_choice': elif question_type == 'multiple_choice':
options = [] options = []
i = 1 i = 1
@@ -593,21 +146,7 @@ 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')
try:
question.credits=request.POST.get('credits',"")
except:
question.credits=""
question.save() question.save()
#return modified(request, pk=quiz.pk)
return redirect('library:detail_quiz', pk=quiz.pk) return redirect('library:detail_quiz', pk=quiz.pk)
else: else:
# Initialize empty question data for new questions # Initialize empty question data for new questions
@@ -620,19 +159,6 @@ def new_question(request):
{'value': 'Falsch', 'is_correct': False} {'value': 'Falsch', 'is_correct': False}
] ]
} }
standard_time_per_question = 30
elif question_type == 'input':
question_data = {
'type': question_type,
'question': '',
'options': [
]
}
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,
@@ -642,14 +168,12 @@ 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)
@@ -658,8 +182,6 @@ 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')
@@ -667,7 +189,6 @@ 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':
@@ -712,12 +233,6 @@ 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':
@@ -730,17 +245,6 @@ def edit_question(request, pk):
{'value': 'Falsch', 'is_correct': not correct_answer} {'value': 'Falsch', 'is_correct': not correct_answer}
] ]
} }
elif question_type == 'input':
correct_answer = request.POST.get('correct_answer')
json_data = {
'type': question_type,
'question': question_text,
'options': [
{'value': correct_answer,'is_correct':True}
]
}
elif question_type == 'multiple_choice': elif question_type == 'multiple_choice':
options = [] options = []
i = 1 i = 1
@@ -770,36 +274,18 @@ 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')
try:
question.credits=request.POST.get('credits',"")
except:
question.credits=""
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 redirect('library:detail_quiz', pk=question.quiz_id.pk) return redirect('library:detail_quiz', pk=question.quiz_id.pk)
else: else:
template_name = f'library/question/question_{question_type}.html' template_name = f'library/question/question_{question_type}.html'
context = { context = {
'credits':question.credits,
'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)
# Frage löschen # Frage löschen
@login_required @login_required
def delete_question(request, pk): def delete_question(request, pk):
@@ -811,11 +297,5 @@ 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})

916
django/package-lock.json generated
View File

@@ -1,916 +0,0 @@
{
"name": "django",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "django",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@tailwindcss/cli": "^4.1.5",
"tailwindcss": "^4.1.5"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"hasInstallScript": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.5.tgz",
"integrity": "sha512-Kr567rDwDjY1VUnfqh5/+DCpRf4B8lPs5O9flP4kri7n4AM2aubrIxGSh5GN8s+awUKw/U4+6kNlEnZbBNfUeg==",
"dependencies": {
"@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.1.5",
"@tailwindcss/oxide": "4.1.5",
"enhanced-resolve": "^5.18.1",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.1.5"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.5.tgz",
"integrity": "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg==",
"dependencies": {
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"lightningcss": "1.29.2",
"tailwindcss": "4.1.5"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.5.tgz",
"integrity": "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA==",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.5",
"@tailwindcss/oxide-darwin-arm64": "4.1.5",
"@tailwindcss/oxide-darwin-x64": "4.1.5",
"@tailwindcss/oxide-freebsd-x64": "4.1.5",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.5",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.5",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.5",
"@tailwindcss/oxide-linux-x64-musl": "4.1.5",
"@tailwindcss/oxide-wasm32-wasi": "4.1.5",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.5",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.5"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.5.tgz",
"integrity": "sha512-LVvM0GirXHED02j7hSECm8l9GGJ1RfgpWCW+DRn5TvSaxVsv28gRtoL4aWKGnXqwvI3zu1GABeDNDVZeDPOQrw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.5.tgz",
"integrity": "sha512-//TfCA3pNrgnw4rRJOqavW7XUk8gsg9ddi8cwcsWXp99tzdBAZW0WXrD8wDyNbqjW316Pk2hiN/NJx/KWHl8oA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.5.tgz",
"integrity": "sha512-XQorp3Q6/WzRd9OalgHgaqgEbjP3qjHrlSUb5k1EuS1Z9NE9+BbzSORraO+ecW432cbCN7RVGGL/lSnHxcd+7Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.5.tgz",
"integrity": "sha512-bPrLWbxo8gAo97ZmrCbOdtlz/Dkuy8NK97aFbVpkJ2nJ2Jo/rsCbu0TlGx8joCuA3q6vMWTSn01JY46iwG+clg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.5.tgz",
"integrity": "sha512-1gtQJY9JzMAhgAfvd/ZaVOjh/Ju/nCoAsvOVJenWZfs05wb8zq+GOTnZALWGqKIYEtyNpCzvMk+ocGpxwdvaVg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.5.tgz",
"integrity": "sha512-dtlaHU2v7MtdxBXoqhxwsWjav7oim7Whc6S9wq/i/uUMTWAzq/gijq1InSgn2yTnh43kR+SFvcSyEF0GCNu1PQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.5.tgz",
"integrity": "sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.5.tgz",
"integrity": "sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.5.tgz",
"integrity": "sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.5.tgz",
"integrity": "sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@emnapi/wasi-threads": "^1.0.2",
"@napi-rs/wasm-runtime": "^0.2.9",
"@tybys/wasm-util": "^0.9.0",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.5.tgz",
"integrity": "sha512-oDKncffWzaovJbkuR7/OTNFRJQVdiw/n8HnzaCItrNQUeQgjy7oUiYpsm9HUBgpmvmDpSSbGaCa2Evzvk3eFmA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.5.tgz",
"integrity": "sha512-WiR4dtyrFdbb+ov0LK+7XsFOsG+0xs0PKZKkt41KDn9jYpO7baE3bXiudPVkTqUEwNfiglCygQHl2jklvSBi7Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
"integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.29.2",
"lightningcss-darwin-x64": "1.29.2",
"lightningcss-freebsd-x64": "1.29.2",
"lightningcss-linux-arm-gnueabihf": "1.29.2",
"lightningcss-linux-arm64-gnu": "1.29.2",
"lightningcss-linux-arm64-musl": "1.29.2",
"lightningcss-linux-x64-gnu": "1.29.2",
"lightningcss-linux-x64-musl": "1.29.2",
"lightningcss-win32-arm64-msvc": "1.29.2",
"lightningcss-win32-x64-msvc": "1.29.2"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
"integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
"integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
"integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
"integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
"integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
"integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
"integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
"integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
"integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss/node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"engines": {
"node": ">=8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"engines": {
"node": ">=4"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tailwindcss": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.5.tgz",
"integrity": "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
}
}

View File

@@ -1,16 +0,0 @@
{
"name": "django",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@tailwindcss/cli": "^4.1.5",
"tailwindcss": "^4.1.5"
}
}

View File

@@ -1,528 +0,0 @@
import json
import asyncio
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.utils import timezone
from datetime import timedelta
from django.urls import reverse
from .models import QuizGame, QuizGameParticipant
class GameConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code']
self.game_group_name = f'game_{self.join_code}'
# Join game group
await self.channel_layer.group_add(
self.game_group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
# Leave game group
await self.channel_layer.group_discard(
self.game_group_name,
self.channel_name
)
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
if message_type == 'submit_answer':
participant_id = text_data_json['participant_id']
answer = text_data_json['answer']
time_remaining = text_data_json.get('time_remaining', 0)
# Save answer and update participant score
await self.save_answer(participant_id, answer, time_remaining)
# Notify host about the new answer
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_answer',
'answer': answer
}
)
elif message_type == 'get_answer_stats':
stats = await self.get_answer_stats()
await self.send(text_data=json.dumps({
'type': 'answer_stats',
'stats': stats
}))
elif message_type == 'update_participants':
participants = await self.get_participants()
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
elif message_type == 'next_question':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.advance_to_next_question()
elif message_type == 'finish_game':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.finish_game()
elif message_type == 'advance_to_scores':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.show_scores()
async def participant_answer(self, event):
await self.send(text_data=json.dumps({
'type': 'participant_answer',
'answer': event['answer']
}))
async def participant_list_update(self, event):
await self.send(text_data=json.dumps({
'type': 'participant_list_update',
'participants': event['participants']
}))
async def game_state_update(self, event):
await self.send(text_data=json.dumps({
'type': 'game_state_update',
'action': event['action'],
'redirect_url': event.get('redirect_url')
}))
@database_sync_to_async
def verify_host(self, host_id):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
return quiz_game.host_id == host_id
except QuizGame.DoesNotExist:
return False
@database_sync_to_async
def save_answer(self, participant_id, answer_index, time_remaining):
try:
participant = QuizGameParticipant.objects.get(
quiz_game__join_code=self.join_code,
participant_id=participant_id
)
quiz_game = participant.quiz_game
current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index]
# Check if answer is correct
is_correct = False
if answer_index >= 0: # -1 means timeout
is_correct = current_question.data['options'][answer_index]['is_correct']
# Calculate score based on correctness and time
score = 0
if is_correct:
base_score = 1000
time_factor = time_remaining / 30000 # 30 seconds max
score = int(base_score * (0.5 + 0.5 * time_factor))
participant.last_score=score
participant.score += score
participant.last_answer_correct = is_correct
participant.save()
return True
except (QuizGameParticipant.DoesNotExist, IndexError):
return False
@database_sync_to_async
def get_answer_stats(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game)
# Count answers for each option
stats = {}
for participant in participants:
if participant.last_answer is not None:
stats[participant.last_answer] = stats.get(participant.last_answer, 0) + 1
return stats
except QuizGame.DoesNotExist:
return {}
@database_sync_to_async
def get_participants(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game)
return [
{
'id': p.participant_id,
'display_name': p.display_name,
'score': p.score
}
for p in participants
]
except QuizGame.DoesNotExist:
return []
@database_sync_to_async
def _advance_to_next_question(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz = quiz_game.quiz_id
# Move to next question
quiz_game.current_question_index += 1
# Check if we've reached the end
if quiz_game.current_question_index >= quiz.questions.count():
quiz_game.current_state = 'finished'
quiz_game.save()
return 'finished'
else:
# Update game state and start time
quiz_game.current_state = 'question'
quiz_game.question_start_time = timezone.now()
quiz_game.save()
return 'question'
except QuizGame.DoesNotExist:
return None
async def advance_to_next_question(self):
result = await self._advance_to_next_question()
if result == 'finished':
# Notify clients to redirect to finished page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'finish_game',
'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code})
}
)
elif result == 'question':
# Notify clients to redirect to next question
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'next_question',
'redirect_url': reverse('play:question', kwargs={'join_code': self.join_code})
}
)
@database_sync_to_async
def _show_scores(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz_game.current_state = 'scores'
quiz_game.save()
return True
except QuizGame.DoesNotExist:
return False
async def show_scores(self):
success = await self._show_scores()
if success:
# Notify clients to redirect to scores page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'show_scores',
'redirect_url': reverse('play:scores', kwargs={'join_code': self.join_code})
}
)
@database_sync_to_async
def _finish_game(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz_game.current_state = 'finished'
quiz_game.save()
return True
except QuizGame.DoesNotExist:
return False
async def finish_game(self):
success = await self._finish_game()
if success:
# Notify clients to redirect to finished page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'finish_game',
'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code})
}
)
class LobbyConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code']
self.room_group_name = f'game_{self.join_code}'
self.heartbeat_task = None
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
# Start heartbeat check
self.heartbeat_task = asyncio.create_task(self.check_participants_heartbeat())
# Send current participants list
participants = await self.get_participants()
await self.send(text_data=json.dumps({
'type': 'participant_list',
'participants': participants
}))
async def disconnect(self, close_code):
# Cancel heartbeat task
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
# Leave room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
if message_type == 'heartbeat':
# Update participant's last activity timestamp
participant_id = text_data_json.get('participant_id')
if participant_id:
await self._update_participant_heartbeat(participant_id)
elif message_type == 'update_participants':
participants = await self.get_participants()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
elif message_type == 'start_game':
# Verify sender is host
sender_id = text_data_json.get('host_id')
if await self._is_host(sender_id):
# Update game state and notify all participants
await self._start_game()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'game_state_update',
'action': 'start_game',
'redirect_url': f'/play/game/{self.join_code}/0'
}
)
async def game_state_update(self, event):
# Send game state update to WebSocket
await self.send(text_data=json.dumps({
'type': 'game_state_update',
'action': event['action'],
'redirect_url': event.get('redirect_url')
}))
@database_sync_to_async
def _start_game(self):
game = QuizGame.objects.get(join_code=self.join_code)
game.current_state = 'question'
game.current_question_index = 0
game.question_start_time = timezone.now()
game.save()
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
if message_type == 'heartbeat':
# Update participant's last activity timestamp
participant_id = text_data_json.get('participant_id')
if participant_id:
await self._update_participant_heartbeat(participant_id)
elif message_type == 'update_participants':
participants = await self.get_participants()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
elif message_type == 'start_game':
# Verify sender is host
sender_id = text_data_json.get('host_id')
if await self._is_host(sender_id):
# Update game state and notify all participants
await self._start_game()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'game_state_update',
'action': 'start_game',
'redirect_url': f'/play/game/{self.join_code}/0'
}
)
elif message_type == 'submit_answer':
participant_id = text_data_json.get('participant_id')
answer = text_data_json.get('answer')
time_remaining = text_data_json.get('time_remaining')
if participant_id:
score = await self._process_answer(participant_id, answer, time_remaining)
await self.send(text_data=json.dumps({
'type': 'answer_processed',
'score': score
}))
elif message_type == 'next_question':
sender_id = text_data_json.get('host_id')
if await self._is_host(sender_id):
next_state = await self._advance_game_state()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'game_state_update',
'action': next_state['action'],
'redirect_url': next_state['redirect_url']
}
)
async def participant_list_update(self, event):
participants = event['participants']
await self.send(text_data=json.dumps({
'type': 'participant_list',
'participants': participants
}))
@database_sync_to_async
def get_participants(self):
game = QuizGame.objects.get(join_code=self.join_code)
participants = QuizGameParticipant.objects.filter(quiz_game=game)
return [{'id': str(p.participant_id), 'name': p.display_name} for p in participants]
@database_sync_to_async
def _update_participant_heartbeat(self, participant_id):
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
participant.save() # This will update last_heartbeat due to auto_now=True
except QuizGameParticipant.DoesNotExist:
pass
@database_sync_to_async
def _is_host(self, host_id):
try:
return QuizGame.objects.filter(join_code=self.join_code, host_id=host_id).exists()
except QuizGame.DoesNotExist:
return False
@database_sync_to_async
def _start_game(self):
game = QuizGame.objects.get(join_code=self.join_code)
game.current_state = 'question'
game.current_question_index = 0
game.question_start_time = timezone.now()
game.save()
@database_sync_to_async
def _process_answer(self, participant_id, answer, time_remaining):
game = QuizGame.objects.get(join_code=self.join_code)
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
question = game.quiz_id.questions.all()[game.current_question_index]
# Load question data
question_data = json.loads(question.data)
correct_answer = question_data.get('correct_answer')
# Calculate score based on correctness and time
score = 0
if answer == correct_answer:
# Base score for correct answer + bonus for speed
score = 1000 + int(time_remaining * 10) # 10 points per remaining second
participant.last_score= score
participant.score += score
participant.save()
return score
@database_sync_to_async
def _advance_game_state(self):
game = QuizGame.objects.get(join_code=self.join_code)
total_questions = game.quiz_id.questions.count()
if game.current_state == 'question':
game.current_state = 'scores'
game.save()
return {
'action': 'show_scores',
'redirect_url': f'/play/game/{self.join_code}/scores'
}
elif game.current_state == 'scores':
if game.current_question_index + 1 < total_questions:
game.current_state = 'question'
game.current_question_index += 1
game.question_start_time = timezone.now()
game.save()
return {
'action': 'next_question',
'redirect_url': f'/play/game/{self.join_code}/{game.current_question_index}'
}
else:
game.current_state = 'finished'
game.save()
return {
'action': 'game_finished',
'redirect_url': f'/play/game/{self.join_code}/finished'
}
@database_sync_to_async
def _remove_inactive_participants(self):
# Remove participants who haven't sent a heartbeat in the last 30 seconds
timeout = timezone.now() - timedelta(seconds=30)
quiz_game = QuizGame.objects.get(join_code=self.join_code)
QuizGameParticipant.objects.filter(
quiz_game=quiz_game,
last_heartbeat__lt=timeout
).delete()
async def check_participants_heartbeat(self):
while True:
try:
await asyncio.sleep(10) # Check every 10 seconds
await self._remove_inactive_participants()
# Send updated participant list
participants = await self.get_participants()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
except asyncio.CancelledError:
break
except Exception as e:
print(f'Error in heartbeat check: {e}')
await asyncio.sleep(10) # Wait before retrying

View File

@@ -1,3 +0,0 @@
from .game import GameConsumer
__all__ = ['GameConsumer']

View File

@@ -1,421 +0,0 @@
import json
import asyncio
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.utils import timezone
from django.urls import reverse
from play.models import QuizGame, QuizGameParticipant, QuizAnswer
from django.conf import settings
class GameConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code']
self.game_group_name = f'game_{self.join_code}'
# Join game group
await self.channel_layer.group_add(
self.game_group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
# Leave game group
await self.channel_layer.group_discard(
self.game_group_name,
self.channel_name
)
# Check if game should be deleted
await self.cleanup_game()
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
if message_type == 'ping':
# Respond with pong to keep connection alive
await self.send(text_data=json.dumps({
'type': 'pong'
}))
return
if message_type == 'leave_game':
participant_id = text_data_json.get('participant_id')
if participant_id:
try:
# Get participant info before deletion
participant = await database_sync_to_async(QuizGameParticipant.objects.get)(participant_id=participant_id)
display_name = participant.display_name
join_code = self.join_code
# Delete participant
await database_sync_to_async(participant.delete)()
# Update participants list
participants = await self.get_participants()
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
# Send redirect to home
await self.send(text_data=json.dumps({
'type': 'redirect',
'url': '/'
}))
except QuizGameParticipant.DoesNotExist:
pass
return
if message_type == 'submit_rating':
participant_id = text_data_json['participant_id']
rating = text_data_json['rating']
success = await self.save_rating(participant_id, rating)
if success:
await self.send(text_data=json.dumps({
'type': 'rating_confirmed'
}))
elif message_type == 'submit_answer':
participant_id = text_data_json['participant_id']
try:
answer = text_data_json['answer']
except:
answer=-1
time_remaining = text_data_json.get('time_remaining', 0)
# Save answer and update participant score
await self.save_answer(participant_id, answer, time_remaining)
# Notify host about the new answer
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_answer',
'answer': answer
}
)
elif message_type == 'start_game':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.advance_to_next_question()
elif message_type == 'get_answer_stats':
stats = await self.get_answer_stats()
await self.send(text_data=json.dumps({
'type': 'answer_stats',
'stats': stats
}))
elif message_type == 'update_participants':
participants = await self.get_participants()
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
elif message_type == 'next_question':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.advance_to_next_question()
elif message_type == 'finish_game':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.finish_game()
elif message_type == 'advance_to_scores':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.show_scores()
async def participant_answer(self, event):
await self.send(text_data=json.dumps({
'type': 'participant_answer',
'answer': event['answer']
}))
async def participant_list_update(self, event):
await self.send(text_data=json.dumps({
'type': 'participant_list_update',
'participants': event['participants']
}))
async def player_left(self, event):
await self.send(text_data=json.dumps({
'type': 'player_left',
'player_name': event['player_name'],
'was_kicked': event.get('was_kicked', False)
}))
async def game_state_update(self, event):
await self.send(text_data=json.dumps({
'type': 'game_state_update',
'action': event['action'],
'redirect_url': event.get('redirect_url')
}))
@database_sync_to_async
def verify_host(self, host_id):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
return quiz_game.host_id == host_id
except QuizGame.DoesNotExist:
return False
@database_sync_to_async
def save_answer(self, participant_id, answer_index, time_remaining):
try:
participant = QuizGameParticipant.objects.get(
quiz_game__join_code=self.join_code,
participant_id=participant_id
)
quiz_game = participant.quiz_game
current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index]
# Check if answer is correct
is_correct = False
question_data = json.loads(current_question.data)
print(answer_index)
try:
if answer_index >= 0 : # -1 means timeout
is_correct = question_data['options'][answer_index]['is_correct']
print(is_correct)
except:
user_input = str(answer_index).strip().lower().replace(" ", "")
correct_value = str(question_data['options'][0].get('value', '')).strip().lower().replace(" ", "")
print("Richtig",correct_value)
print(user_input)
if user_input == correct_value:
is_correct=True
answer_index=int(0)
print(is_correct)
else:
answer_index=int(1)
# Calculate score based on correctness and time
score = 0
if is_correct:
base_score = 1000
time_factor = time_remaining / int(current_question.time_per_question)/int(1000)
score = int(base_score * (0.5 + 0.5 * time_factor))
# Update or create answer in QuizAnswer table
answer_obj, created = QuizAnswer.objects.get_or_create(
participant=participant,
question_index=quiz_game.current_question_index,
defaults={
'answer_index': answer_index,
'is_correct': is_correct,
'score': score,
'time_remaining': time_remaining
}
)
if not created:
answer_obj.answer_index = answer_index
answer_obj.is_correct = is_correct
answer_obj.score = score
answer_obj.time_remaining = time_remaining
answer_obj.save()
participant.last_score = score
participant.score += score
participant.last_answer_correct = is_correct
participant.save()
return True
except (QuizGameParticipant.DoesNotExist, IndexError):
return False
@database_sync_to_async
def get_answer_stats(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
# Count answers for each option in the current question
stats = {}
answers = QuizAnswer.objects.filter(
participant__quiz_game=quiz_game,
question_index=quiz_game.current_question_index
)
for answer in answers:
if answer.answer_index >= 0: # Ignore timeouts (-1)
stats[answer.answer_index] = stats.get(answer.answer_index, 0) + 1
return stats
except QuizGame.DoesNotExist:
return {}
@database_sync_to_async
def save_rating(self, participant_id, rating):
from django.db import models
if not settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']:
return False
from library.models import QuizRating
try:
game = QuizGame.objects.get(join_code=self.join_code)
participant = QuizGameParticipant.objects.get(
quiz_game=game,
participant_id=participant_id
)
# Update or create rating
"""
rating_obj, created = QuizRating.objects.get_or_create(
quiz=game.quiz_id,
participant_id=participant_id,
defaults={'rating': rating}
)
"""
rating_obj=QuizRating.objects.create(
quiz=game.quiz_id,
participant_id=participant_id,
rating=rating)
rating_obj.rating = rating
rating_obj.save()
return True
except (QuizGame.DoesNotExist, QuizGameParticipant.DoesNotExist):
return False
@database_sync_to_async
def cleanup_game(self):
try:
game = QuizGame.objects.get(join_code=self.join_code)
active_participants = QuizGameParticipant.objects.filter(
quiz_game=game
).count()
if active_participants == 0:
game.delete()
except QuizGame.DoesNotExist:
pass
@database_sync_to_async
def get_participants(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game)
return [
{
'id': p.participant_id,
'display_name': p.display_name,
'score': p.score
}
for p in participants
]
except QuizGame.DoesNotExist:
return []
@database_sync_to_async
def _advance_to_next_question(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz = quiz_game.quiz_id
# Move to next question
quiz_game.current_question_index += 1
# Check if we've reached the end
if quiz_game.current_question_index >= quiz.questions.count():
quiz_game.current_state = 'finished'
quiz_game.save()
return 'finished'
else:
# Update game state and start time
quiz_game.current_state = 'question'
quiz_game.question_start_time = timezone.now()
quiz_game.save()
return 'question'
except QuizGame.DoesNotExist:
return None
async def advance_to_next_question(self):
result = await self._advance_to_next_question()
if result == 'finished':
# Notify clients to redirect to finished page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'finish_game',
'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code})
}
)
elif result == 'question':
# Notify clients to redirect to next question
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'next_question',
'redirect_url': reverse('play:question', kwargs={'join_code': self.join_code})
}
)
@database_sync_to_async
def _show_scores(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz_game.current_state = 'scores'
quiz_game.save()
return True
except QuizGame.DoesNotExist:
return False
async def show_scores(self):
success = await self._show_scores()
if success:
# Notify clients to redirect to scores page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'show_scores',
'redirect_url': reverse('play:scores', kwargs={'join_code': self.join_code})
}
)
@database_sync_to_async
def _finish_game(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz_game.current_state = 'finished'
quiz_game.save()
return True
except QuizGame.DoesNotExist:
return False
async def finish_game(self):
success = await self._finish_game()
if success:
# Notify clients to redirect to finished page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'finish_game',
'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code})
}
)

View File

@@ -1,235 +0,0 @@
from channels.generic.websocket import AsyncWebsocketConsumer
import json
import asyncio
from ..models import QuizGame, QuizGameParticipant
from channels.db import database_sync_to_async
import redis
# Dictionary to track inactive check tasks and active connections per game
game_check_tasks = {}
game_connections = {}
class LobbyConsumer(AsyncWebsocketConsumer):
r = redis.Redis(host='localhost', port=6380, db=0, decode_responses=True)
async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code']
self.room_group_name = f'lobby_{self.join_code}'
# Get participant ID from session
self.participant_id = self.scope['session'].get('participant_id')
# Track connection
if self.join_code not in game_connections:
game_connections[self.join_code] = set()
game_connections[self.join_code].add(self.channel_name)
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
# Send initial participants list
await self.update_participants()
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
# Remove from connection tracking
if self.join_code in game_connections:
game_connections[self.join_code].discard(self.channel_name)
if not game_connections[self.join_code]:
# Last connection closed
del game_connections[self.join_code]
if self.join_code in game_check_tasks:
# Cancel the inactive check task
game_check_tasks[self.join_code].cancel()
del game_check_tasks[self.join_code]
async def receive(self, text_data):
data = json.loads(text_data)
message_type = data.get('type')
print(f'Received message type: {message_type}')
if message_type == 'ping':
# Respond with pong to keep connection alive
await self.send(text_data=json.dumps({
'type': 'pong'
}))
return
elif message_type in ['leave_game', 'kick_player']:
participant_id = data.get('participant_id')
if participant_id:
try:
# Get participant info before deletion
participant = await database_sync_to_async(QuizGameParticipant.objects.get)(participant_id=participant_id)
display_name = participant.display_name
# For kick_player, verify that the request comes from the host
if message_type == 'kick_player':
game = await database_sync_to_async(lambda: participant.quiz_game)()
# Get host_id from message
host_id = data.get('host_id')
print(f'Kick attempt - Game host_id: {game.host_id}, Message host_id: {host_id}')
if not host_id:
print('No host_id in message')
return
if str(game.host_id) != str(host_id):
print(f'Host ID mismatch: {game.host_id} != {host_id}')
return
# Also verify against cookie as backup
cookie_host_id = self.scope.get('cookies', {}).get('host_id')
if not cookie_host_id or str(cookie_host_id) != str(host_id):
print(f'Cookie host_id mismatch: {cookie_host_id} != {host_id}')
return
# Delete the participant
await database_sync_to_async(participant.delete)()
# Notify other players
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'player_left',
'player_name': display_name,
'was_kicked': message_type == 'kick_player',
'participant_id': participant_id
}
)
# Update participants list
await self.update_participants()
# If it's the kicked player's connection, send them home
if participant_id == self.participant_id:
await self.send(text_data=json.dumps({
'type': 'redirect',
'url': '/'
}))
except QuizGameParticipant.DoesNotExist:
pass
elif message_type == 'update_participants':
await self.update_participants()
elif message_type == 'start_game':
host_id = data.get('host_id')
if host_id:
success = await self.start_game(host_id)
if success:
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'game_state_update',
'action': 'start_game',
'redirect_url': f'/play/game/{self.join_code}/question'
}
)
async def get_participants_list(self):
try:
from django.utils import timezone
from datetime import timedelta
game = await database_sync_to_async(QuizGame.objects.get)(join_code=self.join_code)
participants = await database_sync_to_async(lambda: list(
game.participants
.values('participant_id', 'display_name')
))()
# Zustand laden
try:
r = redis.Redis(host='localhost', port=6380, 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)
# Neue Teilnehmer bestimmen
new_participants = current_names - prev_names
# Benachrichtigung für neue Teilnehmer versenden
for name in new_participants:
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'player_joined',
'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:
print(f"Error in send_join_notifications: {e}")
self.last_participants = participants
return participants
except QuizGame.DoesNotExist:
return []
async def update_participants(self):
participants = await self.get_participants_list()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'participants_list_update',
'participants': participants
}
)
async def participants_list_update(self, event):
await self.send(text_data=json.dumps({
'type': 'participants_list_update',
'participants': event['participants']
}))
@database_sync_to_async
def start_game(self, host_id):
try:
game = QuizGame.objects.get(join_code=self.join_code)
if str(game.host_id) == str(host_id):
from django.utils import timezone
game.current_state = 'question'
game.question_start_time = timezone.now()
game.save()
return True
except QuizGame.DoesNotExist:
pass
return False
async def game_state_update(self, event):
await self.send(text_data=json.dumps({
'type': 'game_state_update',
'action': event['action'],
'redirect_url': event.get('redirect_url')
}))
async def player_joined(self, event):
await self.send(text_data=json.dumps({
'type': 'player_joined',
'player_name': event['player_name']
}))
async def player_left(self, event):
message = {
'type': 'player_left',
'player_name': event['player_name'],
'was_kicked': event.get('was_kicked', False)
}
if event.get('was_kicked') and event.get('participant_id'):
message['participant_id'] = event['participant_id']
await self.send(text_data=json.dumps(message))

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 10:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0003_quizgameparticipant_participant_id_and_more'),
]
operations = [
migrations.RemoveField(
model_name='quizgame',
name='host_user',
),
migrations.AddField(
model_name='quizgame',
name='host_id',
field=models.CharField(default=None, max_length=200, unique=True),
preserve_default=False,
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 12:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0004_remove_quizgame_host_user_quizgame_host_id'),
]
operations = [
migrations.AddField(
model_name='quizgameparticipant',
name='last_heartbeat',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0005_quizgameparticipant_last_heartbeat'),
]
operations = [
migrations.AddField(
model_name='quizgame',
name='current_question_index',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='quizgame',
name='current_state',
field=models.CharField(choices=[('lobby', 'In Lobby'), ('question', 'Frage läuft'), ('scores', 'Punkteübersicht'), ('finished', 'Beendet')], default='lobby', max_length=20),
),
migrations.AddField(
model_name='quizgame',
name='question_start_time',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 18:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0006_quizgame_current_question_index_and_more'),
]
operations = [
migrations.AlterField(
model_name='quizgameparticipant',
name='quiz_game',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='play.quizgame'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 18:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0007_alter_quizgameparticipant_quiz_game'),
]
operations = [
migrations.AddField(
model_name='quizgameparticipant',
name='last_answer',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='quizgameparticipant',
name='last_answer_correct',
field=models.BooleanField(blank=True, null=True),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 20:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0008_quizgameparticipant_last_answer_and_more'),
]
operations = [
migrations.CreateModel(
name='QuizAnswer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question_index', models.IntegerField()),
('answer_index', models.IntegerField()),
('is_correct', models.BooleanField()),
('score', models.IntegerField()),
('time_remaining', models.IntegerField()),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='play.quizgameparticipant')),
],
options={
'unique_together': {('participant', 'question_index')},
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-06 17:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0009_quizanswer'),
]
operations = [
migrations.AlterField(
model_name='quizgameparticipant',
name='last_heartbeat',
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -1,18 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-05-22 15:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0011_quizgame_updated_at'),
]
operations = [
migrations.AddField(
model_name='quizgameparticipant',
name='last_score',
field=models.IntegerField(default=0, verbose_name='Letzte_Punkte'),
),
]

View File

@@ -6,20 +6,9 @@ import random, string
# Create your models here. # Create your models here.
class QuizGame(models.Model): class QuizGame(models.Model):
GAME_STATES = [ host_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='host_user')
('lobby', 'In Lobby'),
('question', 'Frage läuft'),
('scores', 'Punkteübersicht'),
('finished', 'Beendet')
]
host_id = models.CharField(max_length=200, unique=True)
join_code = models.CharField(max_length=6, unique=True, blank=True) join_code = models.CharField(max_length=6, unique=True, blank=True)
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE) quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE)
current_state = models.CharField(max_length=20, choices=GAME_STATES, default='lobby')
current_question_index = models.IntegerField(default=0)
question_start_time = models.DateTimeField(null=True, blank=True)
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:
@@ -32,22 +21,12 @@ class QuizGame(models.Model):
if not QuizGame.objects.filter(join_code=new_code).exists(): if not QuizGame.objects.filter(join_code=new_code).exists():
return new_code return new_code
def generate_unique_id(self):
for i in range(10):
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
return new_id
class QuizGameParticipant(models.Model): class QuizGameParticipant(models.Model):
participant_id = models.CharField(max_length=200, unique=True) participant_id = models.CharField(max_length=200, unique=True)
display_name = models.CharField(verbose_name="Anzeigename", max_length=15) display_name = models.CharField(verbose_name="Anzeigename", max_length=15)
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participants") quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participant")
score = models.IntegerField(verbose_name="Punkte", default=0) score = models.IntegerField(verbose_name="Punkte", default=0)
last_score = models.IntegerField(verbose_name="Letzte_Punkte", default=0)
avatar = models.CharField(max_length=200, blank=True, default="") avatar = models.CharField(max_length=200, blank=True, default="")
last_heartbeat = models.DateTimeField(auto_now_add=True)
last_answer = models.IntegerField(null=True, blank=True)
last_answer_correct = models.BooleanField(null=True, blank=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.participant_id: if not self.participant_id:
@@ -59,20 +38,3 @@ class QuizGameParticipant(models.Model):
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60)) new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists(): if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
return new_id return new_id
class QuizAnswer(models.Model):
participant = models.ForeignKey(QuizGameParticipant, on_delete=models.CASCADE, related_name='answers')
question_index = models.IntegerField()
answer_index = models.IntegerField()
is_correct = models.BooleanField()
score = models.IntegerField()
time_remaining = models.IntegerField()
class Meta:
unique_together = ['participant', 'question_index']
def generate_unique_id(self):
for i in range(10):
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
return new_id

View File

@@ -1,8 +0,0 @@
from django.urls import re_path
from .consumers.game import GameConsumer
from .consumers.lobby import LobbyConsumer
websocket_urlpatterns = [
re_path(r'ws/game/(?P<join_code>\w+)/$', GameConsumer.as_asgi()),
re_path(r'ws/play/lobby/(?P<join_code>\w+)/$', LobbyConsumer.as_asgi()),
]

View File

@@ -4,14 +4,7 @@ from . import views
app_name = 'play' app_name = 'play'
urlpatterns = [ urlpatterns = [
path('game/new/<int:quiz_id>', views.create_game, name='create_game'), path('lobby/<str:join_code>', views.lobby, name='lobby'),
path('game/<str:join_code>', views.lobby, name='lobby'), path('lobby/<str:join_code>/participate', views.create_participant, name='create_participant'),
path('game/<str:join_code>/participate', views.create_participant, name='create_participant'),
path('join', views.join_game, name='join_game'), path('join', views.join_game, name='join_game'),
path('game/<str:join_code>/question', views.question, name='question'),
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>/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,377 +1,60 @@
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 from library.models import QivipQuiz
from django.http import HttpResponseRedirect, HttpResponseNotFound from django.http import HttpResponseRedirect
from django.contrib import messages
from django.conf import settings
import json
import qrcode
import base64
from io import BytesIO
from django.shortcuts import render
# Create your views here. # Create your views here.
def lobby(request, join_code): def lobby(request, join_code):
relative_url = reverse("play:create_participant", kwargs={"join_code": join_code})
full_url = request.build_absolute_uri(relative_url)
qr = qrcode.make(full_url)
buffer = BytesIO()
qr.save(buffer, format="PNG")
img_base64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
context = {
'quiz': quiz,
'quiz_game': quiz_game,
"qr_code_base64": img_base64,
}
mode = request.GET.get('mode','default')
request.session['mode'] = mode
if "host_id" in request.COOKIES:
host_id = request.COOKIES['host_id']
if host_id == quiz_game.host_id:
context['host_id'] = host_id
context["qr_code_base64"] = img_base64
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)
participant_id = request.COOKIES['participant_id'] participant_id = request.COOKIES['participant_id']
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
if not participant.quiz_game.join_code == join_code: # Teilnehmer dem richtigen Spiel hinzufügen
participant.quiz_game = quiz_game
participant.score = 0 # Reset score when joining new game
participant.last_score = 0
participant.save()
except QuizGameParticipant.DoesNotExist:
return redirect('play:create_participant', join_code=join_code)
context['participant'] = participant
context["qr_code_base64"] = img_base64
return render(request, 'play/lobby.html', context=context)
def select_mode(request,join_code):
return render(request, 'play/select_mode.html', {'join_code': join_code})
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) quiz_game = get_object_or_404(QuizGame, join_code=join_code)
if mode!="learn": quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
if "participant_id" in request.COOKIES: participant = get_object_or_404(QuizGameParticipant, participant_id=participant_id)
# Prüfe ob der Teilnehmer noch existiert
participant_id = request.COOKIES['participant_id']
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
if participant.quiz_game.join_code != join_code:
participant.quiz_game = quiz_game
participant.score = 0 # Reset score when joining new game
participant.last_score = 0
participant.save()
return redirect('play:lobby', join_code=join_code)
except QuizGameParticipant.DoesNotExist:
# Cookie löschen wenn Teilnehmer nicht mehr existiert
response = HttpResponseRedirect(request.path)
response.delete_cookie('participant_id')
return response
if request.method == 'POST': if not participant.quiz_game.join_code == join_code:
display_name = request.POST.get('display_name')
if not display_name:
return render(request, 'play/initialize_participant.html', {
'join_code': join_code,
'error': 'Bitte geben Sie einen Namen ein'
})
participant = QuizGameParticipant()
participant.display_name = display_name
participant.quiz_game = quiz_game participant.quiz_game = quiz_game
participant.score = 0 # Initialize score
participant.last_score = 0
participant.save() participant.save()
response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': join_code})) context = {
response.set_cookie('participant_id', participant.participant_id, max_age=3600) 'debug': "test",
return response 'participant': participant,
'quiz': quiz,
}
return render(request, 'play/initialize_participant.html', {'join_code': join_code}) return render(request, 'play/lobby.html', context=context)
else:
messages.error(request, "Beitritt nicht möglich.")
return render(request, 'play/join_game.html')
def create_game(request, quiz_id):
try: def create_participant(request, join_code):
quiz = QivipQuiz.objects.get(id=quiz_id) if "participant_id" in request.COOKIES: # Umleiten, falls Teilnehmer bereits erstellt
game = QuizGame() return redirect('play:lobby', join_code=join_code)
game.quiz_id = quiz if request.method == 'POST':
game.host_id = game.generate_unique_id() display_name = request.POST.get('display_name')
game.save() join_code = request.POST.get('join_code')
try:
quiz = QuizGame.objects.get(join_code=join_code)
participant = QuizGameParticipant()
participant.display_name = display_name
participant.quiz_game = quiz
participant.save()
response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': join_code}))
response.set_cookie('participant_id', participant.participant_id, max_age=3600)
response = HttpResponseRedirect(reverse('play:select_mode', kwargs={'join_code': game.join_code})) return response
response.set_cookie('host_id', game.host_id, max_age=3600) except QuizGame.DoesNotExist:
# TODO: Fehlermeldung fuer nicht-existierendes Quiz
return response pass
except QivipQuiz.DoesNotExist:
return HttpResponseNotFound("Quiz nicht gefunden")
return render(request, 'play/initialize_participant.html', {'join_code': join_code})
def join_game(request): def join_game(request):
if request.method == 'POST': if request.method == 'POST':
join_code = request.POST.get('game_code') join_code = request.POST.get('game_code')
try: try:
quiz = QuizGame.objects.get(join_code=join_code) quiz = QuizGame.objects.get(join_code=join_code)
# Redirect to create_participant if no participant cookie exists
if not "participant_id" in request.COOKIES:
return redirect('play:create_participant', join_code=join_code)
return redirect('play:lobby', join_code=join_code) return redirect('play:lobby', join_code=join_code)
except QuizGame.DoesNotExist: except QuizGame.DoesNotExist:
messages.error(request, "Dieses Spiel existiert nicht.") # TODO: Mit message eine Fehlermeldung weitergeben (und im entsprechenden Template Designen)
pass
return render(request, 'play/join_game.html') return render(request, 'play/join_game.html')
def scores(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Verify game state
if quiz_game.current_state != 'scores':
return redirect('play:lobby', join_code=join_code)
# Get participants sorted by score
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score')
# Check if user is host
is_host = False
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
is_host = True
# Get current question for context
questions = quiz_game.quiz_id.questions.all()
current_question = questions[quiz_game.current_question_index]
question_data = json.loads(current_question.data)
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', {
'quiz_game': quiz_game,
'participants': participants,
'participant': participant,
'my_participant':my_participant,
'is_host': is_host,
'host_id': quiz_game.host_id if is_host else None,
'is_last_question': quiz_game.current_question_index + 1 >= len(questions),
'question_data': question_data,
'question_index': quiz_game.current_question_index,
'total_questions': len(questions)
})
"""
def finished(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Verify game state
if quiz_game.current_state != 'finished':
return redirect('play:lobby', join_code=join_code)
# Get participants sorted by score
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score')
# Check if user is host
is_host = False
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
is_host = True
# Get participant if exists
participant_id = request.COOKIES.get('participant_id')
participant = None
if participant_id:
participant = QuizGameParticipant.objects.filter(
quiz_game=quiz_game,
participant_id=participant_id
).first()
return render(request, 'play/game/finished.html', {
'quiz_game': quiz_game,
'participants': participants,
'winner': participants.first() if participants.exists() else None,
'join_code': join_code,
'is_host': is_host,
'participant_id': participant_id if participant else None
})
"""
def finished(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
if quiz_game.current_state != 'finished':
return redirect('play:lobby', join_code=join_code)
try:
game = QuizGame.objects.get(join_code=join_code)
# Get participants sorted by score
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score')
# Get participant if exists
participant_id = request.COOKIES.get('participant_id')
participant = None
if participant_id:
participant = QuizGameParticipant.objects.filter(
quiz_game=quiz_game,
participant_id=participant_id
).first()
is_host = False
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
is_host = True
context = {
'join_code': join_code,
'is_host': is_host,
'participants': participants,
'participant_id': participant_id if participant else None,
'rating_enabled': settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']
}
return render(request, 'play/game/finished.html', context)
except QuizGame.DoesNotExist:
return redirect('home')
def question(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Verify game state
if quiz_game.current_state != 'question':
return redirect('play:lobby', join_code=join_code)
# Get current question
questions = quiz_game.quiz_id.questions.all()
if quiz_game.current_question_index >= len(questions):
return redirect('play:lobby', join_code=join_code)
current_question = questions[quiz_game.current_question_index]
question_data = json.loads(current_question.data)
# Check if user is host
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
return render(request, 'play/game/question_host.html', {
'quiz_game': quiz_game,
'question_data': question_data,
'current_question':current_question,
'host_id': quiz_game.host_id,
'start_time': int(quiz_game.question_start_time.timestamp() * 1000),
'question_index': quiz_game.current_question_index,
'total_questions': len(questions),
'question_image': current_question.image
})
# Handle participant view
if not 'participant_id' in request.COOKIES:
return redirect('play:create_participant', join_code=join_code)
participant_id = request.COOKIES['participant_id']
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
except QuizGameParticipant.DoesNotExist:
return redirect('play:create_participant', join_code=join_code)
request.session['my_participant-participant_id'] = participant.participant_id
return render(request, 'play/game/question_participant.html', {
'quiz_game': quiz_game,
'question_data': question_data,
'current_question':current_question,
'participant': participant,
'start_time': int(quiz_game.question_start_time.timestamp() * 1000),
'question_index': quiz_game.current_question_index,
'total_questions': len(questions),
})
def waiting_room(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Check if user is host
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
return redirect('play:question', join_code=join_code)
# Handle participant view
if not 'participant_id' in request.COOKIES:
return redirect('play:create_participant', join_code=join_code)
participant_id = request.COOKIES['participant_id']
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
except QuizGameParticipant.DoesNotExist:
return redirect('play:create_participant', join_code=join_code)
return render(request, 'play/game/wait_for_other_players.html', {
'quiz_game': quiz_game,
'participant': participant
})
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.last_score = 0
participant.save()
quiz_game.current_state = "question"
quiz_game.question_start_time = timezone.now()
quiz_game.save()
return redirect('play:question',join_code)
return redirect('play:lobby', join_code=join_code)

View File

@@ -51,74 +51,3 @@ a.qp-a-button-small {
@apply p-3 rounded-full bg-indigo-500 hover:bg-indigo-600 text-white focus:scale-105 @apply p-3 rounded-full bg-indigo-500 hover:bg-indigo-600 text-white focus:scale-105
transition duration-200 font-black shadow-md hover:shadow-lg; transition duration-200 font-black shadow-md hover:shadow-lg;
} }
ul.messages li {
@apply p-3 rounded-full bg-gray-200 hover:scale-105 transition duration-200 font-black
focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-blue-100;
}
ul.messages li.error {
@apply bg-red-200;
}
div.qp-answer-container {
@apply grid grid-cols-2 h-screen;
}
div.qp-answer-container div {
@apply grid place-content-center rounded-xl m-2 place-self-stretch text-white transition duration-300;
@apply nth-1:bg-green-500 hover:nth-1:bg-green-600;
@apply nth-2:bg-red-500 hover:nth-2:bg-red-600;
@apply nth-3:bg-blue-500 hover:nth-3:bg-blue-600;
@apply nth-4:bg-yellow-500 hover:nth-4:bg-yellow-600;
@apply nth-5:bg-purple-500 hover:nth-5:bg-purple-600;
@apply nth-6:bg-black hover:nth-6:bg-slate-800;
}
div.qp-answer-container div p {
@apply font-black text-xl sm:text-2xl md:text-4xl;
}
div.qp-answer-container-host {
@apply grid grid-cols-2 absolute bottom-0 w-screen xl:px-20 xl:pb-10;
}
div.qp-answer-container-host div {
@apply grid place-content-center rounded-xl m-1 place-self-stretch text-white transition duration-300;
@apply nth-1:bg-green-500 hover:nth-1:bg-green-600;
@apply nth-2:bg-red-500 hover:nth-2:bg-red-600;
@apply nth-3:bg-blue-500 hover:nth-3:bg-blue-600;
@apply nth-4:bg-yellow-500 hover:nth-4:bg-yellow-600;
@apply nth-5:bg-purple-500 hover:nth-5:bg-purple-600;
@apply nth-6:bg-black hover:nth-6:bg-slate-800;
}
div.qp-answer-container-host div p {
@apply font-black text-xl p-4;
}
/* Filter Panel Styles */
#filterPanel {
@apply hidden transition-all duration-300 ease-in-out;
}
#filterPanel.show {
@apply block;
}
div.qp-question-container {
@apply text-center text-3xl px-4 py-9 h-screen;
}
div.qp-question-container img {
@apply max-w-[50vw] max-h-[40vh] mx-auto;
}

View File

@@ -1,35 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="max-w-2xl mx-auto mt-20 p-6 bg-white shadow-xl rounded-2xl text-center dark:bg-transparent">
<div class="flex justify-center items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="w-10 h-10 text-red-600">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<h1 class=" text-4xl font-extrabold text-red-600">404 Fehler</h1>
</div>
<div class="my-6 flex justify-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-24 h-24 text-gray-400">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.182 16.318A4.486 4.486 0 0 0 12.016 15a4.486 4.486 0 0 0-3.198 1.318M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" />
</svg>
</div>
<h2 class="text-2xl font-semibold text-gray-800 mb-2 dark:text-white ">Seite nicht gefunden</h2>
<p class="text-lg text-gray-600 mb-6 dark:text-white">Ups, die angeforderte Seite wurde nicht gefunden. Überprüfen Sie die URL oder kehren Sie zur Startseite zurück.</p>
<div class="flex justify-center">
<a href="{% url 'homepage:home' %}"
class="bg-blue-600 hover:bg-blue-700 transition-colors text-white px-6 py-3 rounded-full text-lg font-semibold shadow-lg">
Zur Startseite
</a>
</div>
</div>
{% endblock %}

View File

@@ -3,41 +3,19 @@
{% block content %} {% block content %}
<div class="flex justify-center items-center mt-12"> <div class="flex justify-center items-center mt-12 ">
<div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md dark:bg-transparent! dark:text-white! "> <div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md">
<div class="grid place-content-center h-full mb-4 flex-col items-center justify-between"> <div class="grid place-content-center h-120">
<h1 class="text-center m-4 text-3xl lg:text-6xl">Willkommen, <b>{{ request.user.username }}</b>!</h1> <h1 class="text-center m-4 text-3xl lg:text-6xl">Willkommen, <b>{{ request.user.username }}</b>!</h1>
<form action="{% url 'accounts:logout' %}" method="post"> <form action="{% url 'accounts:logout' %}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200 cursor-pointer" type="submit">Abmelden</button> <button class="bg-blue-600 text-white p-3 rounded-full font-black" type="submit">Abmelden</button>
</form> </form>
<button class="text-center text-sm w-full mt-4 font-light text-blue-600"><a href="{% url 'accounts:password_change' %}">Passwort ändern</a></button>
<button class="text-center text-sm w-full mt-4 font-light text-red-600"><a href="{% url 'accounts:delete_account' %}">Account löschen</a></button>
<div class="border-2 border-blue-600 p-6 rounded-xl shadow-inner bg-blue-50 flex flex-col gap-4 items-start mt-8 dark:bg-transparent">
<div class="flex items-center gap-2 text-xl font-semibold text-blue-800 dark: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="w-6 h-6 text-yellow-500">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 0 1-.982-3.172M9.497 14.25a7.454 7.454 0 0 0 .981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 0 0 7.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 0 0 2.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 0 1 2.916.52 6.003 6.003 0 0 1-5.395 4.972m0 0a6.726 6.726 0 0 1-2.749 1.35m0 0a6.772 6.772 0 0 1-3.044 0" />
</svg>
Erfolge
</div>
<ul class="list-disc pl-6 text-base text-gray-700 dark:text-white">
<li><b>{{ my_quizzes_number }}</b> Quizze erstellt</li>
<li><b>{{ my_questions_number }}</b> Fragen insgesamt erstellt</li>
</ul>
</div>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white dark:bg-transparent">qivip <span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white">qivip
</span> </span>
</div> </div>
</div> </div>

View File

@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="grid place-content-center h-120 mt-12 mb-12 "> <div class="grid place-content-center h-120 mt-12 mb-12 ">
<div class="w-80 p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md lg:w-90"> <div class="w-80 p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md lg:w-90">
<h2 class="text-2xl text-center p-4 font-black dark:text-white">Anmeldung</h2> <h2 class="text-2xl text-center p-4 font-black">Anmeldung</h2>
<form class="space-y-4" method="post"> <form class="space-y-4" method="post">
{% if form.errors %} {% if form.errors %}
<div class=" items-center text-red-600 font-black overflow-x-auto break-words text-center"> <div class=" items-center text-red-600 font-black overflow-x-auto break-words text-center">
@@ -24,10 +24,9 @@
<div class="items-center w-full"> <div class="items-center w-full">
<input class=" w-full p-3 rounded-full bg-gray-200 focus:scale-105 transition duration-200 font-black" type="password" name="password" id="password" required placeholder="PASSWORT"> <input class=" w-full p-3 rounded-full bg-gray-200 focus:scale-105 transition duration-200 font-black" type="password" name="password" id="password" required placeholder="PASSWORT">
</div> </div>
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200 cursor-pointer" type="submit" class="login-button">Anmelden</button> <button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit" class="login-button">Anmelden</button>
</form> </form>
<button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:register' %}" class="register_link">Noch kein Konto?</a></button> <button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:register' %}" class="register_link">Noch kein Konto?</a></button>
<button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:password_reset' %}" class="register_link">Passwort vergessen?</a></button>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -4,7 +4,7 @@
<div class="grid place-content-center h-120 mt-12 mb-12"> <div class="grid place-content-center h-120 mt-12 mb-12">
<div class="w-80 p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md lg:w-90"> <div class="w-80 p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md lg:w-90">
<h2 class="text-2xl text-center p-4 font-black dark:text-white">Registrieren</h2> <h2 class="text-2xl text-center p-4 font-black">Registrieren</h2>
<form class=" space-y-4" method="post"> <form class=" space-y-4" method="post">
{% if form.errors %} {% if form.errors %}
@@ -37,14 +37,7 @@
{{ form.password2 }} {{ form.password2 }}
</div> </div>
<div class="register">
{{ form.email }}
</div>
<div class="text-center text-gray-600 text-sm dislay-flex align-bottom">
<label class=''>Mit der Erstellung ihres Kontos stimmen Sie der <a class="text-blue-600" href="/privacy">Datenschutzerklärung </a> zu.</label>
</div>
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit" class="login-button">Registrieren</button> <button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit" class="login-button">Registrieren</button>
</form> </form>
<button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:login' %}" class="register_link">Bereits ein Konto?</a></button> <button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:login' %}" class="register_link">Bereits ein Konto?</a></button>

View File

@@ -1,80 +1,17 @@
{% load static %} {% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de" class="dark:bg-gray-900"> <html lang="de">
<head> <head>
<link rel="icon" type="image/png" href="{% static 'icons/favicon.png' %}"> <link rel="icon" type="image/png" href="{% static 'icons/favicon.png' %}">
<meta charset="UTF-8"> <meta charset="UTF-8">
<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">
<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>
{% include 'partials/_nav.html' %} {% include 'partials/_nav.html' %}
{% block content%}{% endblock %} {% block content%}{% endblock %}
{% block faqs%}{% endblock %}
{% include 'partials/_footer.html' %} {% include 'partials/_footer.html' %}
<!-- Toast Container -->
<div id="toast-container" class="fixed top-4 right-4 z-50"></div>
<!-- Django Messages to Toast -->
{% if messages %}
<script>
document.addEventListener('DOMContentLoaded', function() {
{% for message in messages %}
// Use the existing toast.create function
toast.create("{{ message }}", "{{ message.tags }}");
{% endfor %}
});
</script>
{% endif %}
<script>
// WebSocket Utility
const wsUtil = {
handleClose: function(e) {
if (e.code === 1000 || e.code === 1001) {
console.log('Socket closed normally');
} else {
console.error('Socket closed unexpectedly', e.code);
}
}
};
// Toast Notification System
const toast = {
create: function(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
toast.className = `mb-4 p-4 rounded-lg shadow-lg transform transition-all duration-300 ease-in-out
${type === 'success' ? 'bg-green-100 border-l-4 border-green-500 text-green-700' :
type === 'error' ? 'bg-red-100 border-l-4 border-red-500 text-red-700' :
type === 'warning' ? 'bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700' :
'bg-blue-100 border-l-4 border-blue-500 text-blue-700'}`;
toast.innerHTML = message;
const container = document.getElementById('toast-container');
container.appendChild(toast);
// Animate in
setTimeout(() => toast.classList.add('translate-x-0', 'opacity-100'), 100);
toast.classList.add('-translate-x-4', 'opacity-0');
// Remove after duration
setTimeout(() => {
toast.classList.add('-translate-x-4', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, duration);
}
};
if (window.location.pathname !== "/library/") {
localStorage.setItem("meine_seite_scroll", 0);}
</script>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
<style>html {
overflow-y: scroll;
}</style>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,22 @@
{% extends 'components/base.html' %}
{% load static %}
{% block title %}Antwort{% endblock %}
{% block content %}
<link rel="stylesheet" href="{% static 'homepage/t-style.css' %}">
<!-- TODO Bedingung festlegen: Quiztyp unterscheiden % if <Bedingung> % -->
{% if user.is_authenticated %}
<button class="overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-red-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">1</button>
<button class="overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-indigo-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">2</button>
<button class="overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-yellow-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">3</button>
<button class="overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-green-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">4</button>
<!-- TODO wieder einfügen, wenn Bedingung gestetzt -->
{% else %}
<button class=" overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-green-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">wahr</button>
<button class=" overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-red-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">falsch</button>
<!--TODO wieder einfügen, wenn Bedingung gestetzt plus -->
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% load static %}
<!doctype html>
<html class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body class="dark:bg-gray-900 h-full m-0">
<div style="height: calc(12vh);" class=" m-0 text-white bg-blue-600 rounded-lg h-12 shadow-md font-semibold px-1 w-full h-20 shadow-[0_2px_2px_rgba(0,0,0,0.1)] break-words text-center flex items-center justify-center overflow-x-auto text-[clamp(0.55rem,5vw,1.5rem)]" > Frage: Wie hoch ist der Eiffelturm insgesamt?
</div>
<div class=" dark:bg-gray-900 flex justify-end gap-2 " style="height: calc(12vh);">
<span class="mt-4 text-[clamp(0.6rem,5vw,1rem)] overflow-x-auto ml-0.75 text-white bg-blue-400 mb-6 h-12 rounded-sm p-3 shadow-[0_2px_2px_rgba(0,0,0,0.1)] z-40 items-center">Zeit: 60s</span>
<div class="mt-4 text-[clamp(0.6rem,5vw,1rem)] overflow-x-auto mr-0.75 bg-gray-500 text-white h-12 mb-6 rounded-sm p-3 shadow-[0_2px_2px_rgba(0,0,0,0.1)] z-40 items-center ">Deine Punkte: 60</div>
</div>
<div class="mt-3 flex justify-center -z-1 " style="height: calc(37vh);"> <img class="object-cover " src="{% static 'components/Ausgabe.png' %}" alt="Bild"> </div>
<div class="w-full grid grid-cols-2 p-2 h-full" style="height: calc(38vh);min-height: 50px;">
{% block content %}
{% endblock %}
</div>
<div class="absolute bottom-0 right-0">
<span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white">qivip
</span>
</div>
 </body>
</html>

View File

@@ -0,0 +1,63 @@
{% load static %}
<link rel="stylesheet" href="{% static 'homepage/t-style.css' %}">
<!doctype html>
<html class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body class=" dark:bg-gray-900 w-full justify-center h-full m-0">
<div style="height: calc(10vh);" class=" m-0 text-white bg-blue-600 rounded-lg h-12 shadow-md font-semibold px-1 w-full h-20 shadow-[0_2px_2px_rgba(0,0,0,0.1)] break-words text-center flex items-center justify-center overflow-x-auto text-[clamp(0.75rem,5vw,1.5rem)]" >Ranking
</div>
<div class="m-4 flex justify-end font-bold">Frage 1/10</div>
<div class="text-[clamp(0.75rem,5vw,1.25rem)] font-bold flex items-center justify-around border-blue-600 border-4 space-x-2 mt-2 overflow-x-auto rounded-lg p-4 shadow-[0_2px_2px_rgba(0,0,0,0.1)] w-full">
<div style="background:linear-gradient(120deg, rgb(255, 208, 0),rgb(216, 151, 21))" class="text-white !important px-4 rounded-sm">1. Platz</div>
<div class="text-stone-900 ">6000 Punkte</div>
<div class="">Person XY</div>
</div>
<div class=" text-[clamp(0.75rem,5vw,1.25rem)] font-bold p-4 flex items-center justify-around border-blue-600 border-4 space-x-2 mt-2 overflow-x-auto rounded-lg shadow-[0_2px_2px_rgba(0,0,0,0.1)] w-full">
<div style="background:linear-gradient(120deg, rgb(102, 122, 122),rgb(194, 186, 186))" class="text-white !important px-4 rounded-sm">2. Platz</div>
<div class="text-stone-900 ">5000 Punkte</div>
<div class="">Person XY</div>
</div>
<div class=" text-[clamp(0.75rem,5vw,1.25rem)] font-bold flex items-center justify-around border-blue-600 border-4 space-x-2 mt-2 overflow-x-auto rounded-lg p-4 shadow-[0_2px_2px_rgba(0,0,0,0.1)] w-full">
<div style="background:linear-gradient(120deg, rgb(211, 91, 17),rgb(219, 168, 109))" class="text-white !important px-4 rounded-sm">3. Platz</div>
<div class="text-stone-900 ">600 Punkte</div>
<div class="">Person XY</div>
</div>
<div class=" text-[clamp(0.75rem,5vw,1.25rem)] items-center flex justify-around font-bold border-blue-600 border-4 space-x-2 mt-2 text-[clamp(0.6rem,5vw,1rem)] overflow-x-auto rounded-lg p-4 shadow-[0_2px_2px_rgba(0,0,0,0.1)] w-full">
<div>4. Platz</div>
<div>200 Punkte</div>
<div>Person XY</div>
</div>
<div class="absolute bottom-0 right-0">
<span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white">qivip
</span>
</div>
 </body>
</html>

View File

@@ -4,55 +4,12 @@
<div class="grid place-content-center md:h-screen h-150 w-full -z-50 top-0 p-4"> <div class="grid place-content-center md:h-screen h-150 w-full -z-50 top-0 p-4">
<div class="text-center"> <div class="text-center">
<h2 class="font-bold md:text-8xl text-6xl md:mb-8 text-blue-600">qivip</h2> <h2 class="font-bold md:text-8xl text-6xl md:mb-8 text-blue-600">qivip</h2>
<h3 class="mt-4 italic font-light sm:text-4xl text-md bg-gradient-to-r from-pink-500 via-purple-600 to-blue-700 bg-clip-text text-transparent">weil Lernen auch Spaß <br> machen kann!</h3> <h3 class="italic font-extralight sm:text-2xl text-md">Interaktives Lernen neu definiert.</h3>
</div> </div>
<div class="grid sm:grid-cols-3 place-items-stretch text-center mt-8 gap-4 p-4 border-3 bg-blue-100 border-blue-100 rounded-md dark:bg-gray-900 dark:border-0"> <div class="grid sm:grid-cols-3 place-items-stretch text-center mt-8 gap-4 p-4 border-3 bg-blue-100 border-blue-100 rounded-md">
<a class="qp-a-button bg-green-500 text-white" href="{% url 'play:join_game' %}">Teilnehmen</a> <a class="qp-a-button bg-green-500 text-white" href="{% url 'play:join_game' %}">Teilnehmen</a>
<a class="qp-a-button bg-indigo-500 text-white" href="{% url 'library:overview_quiz' %}">Moderieren</a> <a class="qp-a-button bg-indigo-500 text-white" href="#">Moderieren</a>
<a class="qp-a-button bg-purple-500 text-white" href="{% url 'library:new_quiz' %}">Erstellen</a> <a class="qp-a-button bg-purple-500 text-white" href="{% url 'library:new_quiz' %}">Erstellen</a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block faqs %}
<style>
details[open] summary svg {
transform: rotate(180deg);
}
</style>
{% if faqs %}
<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 class="text-2xl font-bold text-gray-900 dark:text-white">FAQs</h2>
<div class="h-0.5 flex-grow bg-gradient-to-r from-blue-600 to-transparent rounded-full"></div>
</div>
</div>
<div class="input-group !max-w-4xl tracking-wide dark:!bg-gray-900 dark:!text-white">
<h1 class="font-black text-blue-600 flex justify-between gap-2 lg:text-lg">
FAQs - häufig gestellte Fragen
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg></h1>
</div>
{% for faq in faqs %}
<div class="input-group mt-2.5 !max-w-4xl hover:bg-gray-50 dark:!bg-gray-900 dark:!text-white ">
<details>
<summary class="flex justify-between cursor-pointer font-bold">{{ faq.question }}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">{{ faq.answer }}</p>
</details>
</div>
{% endfor %}
{% endif %}
{% endblock %}

View File

@@ -1,8 +1,8 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content%} {% block content%}
<div class="flex justify-center items-center mt-12"> <div class="flex justify-center items-center mt-12 ">
<div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md dark:!bg-transparent dark:!text-white "> <div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md">
<div class="grid place-content-center h-120"> <div class="grid place-content-center h-120">
<h1 class="text-center m-4 text-3xl lg:text-6xl">Impressum</b></h1> <h1 class="text-center m-4 text-3xl lg:text-6xl">Impressum</b></h1>
@@ -12,7 +12,7 @@
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white dark:bg-transparent ">qivip <span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white">qivip
</span> </span>
</div> </div>
</div> </div>

View File

@@ -2,17 +2,17 @@
{% block content%} {% block content%}
<div class="flex justify-center items-center mt-12 "> <div class="flex justify-center items-center mt-12 ">
<div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md dark:!bg-transparent dark:!text-white"> <div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md">
<div class="grid place-content-center h-120"> <div class="grid place-content-center h-120">
<h1 class="text-center m-4 text-3xl lg:text-6xl">Datenschutz-</br>erklärung</h1> <h1 class="text-center m-4 text-3xl break-words lg:text-6xl">Datenschutz-<br>erklärung</b></h1>
Hier muss die Datenschutzerklärung stehen! <br> Hier muss die Datenschutzerklärung stehen! <br>
<br> <br>
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white dark:!bg-transparent ">qivip <span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white">qivip
</span> </span>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More