Merge branch '143-zeitangabe-fur-quizze' into 'master'

Resolve "Zeitangabe für Quizze"

Closes #143

See merge request enrichment-2024/qivip!98
This commit is contained in:
ben8
2025-06-27 13:19:14 +00:00
11 changed files with 195 additions and 50 deletions

View File

@@ -56,6 +56,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'channels', 'channels',
'django_cleanup', 'django_cleanup',
'django.contrib.humanize',
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@@ -1,9 +1,9 @@
from django.contrib import admin from django.contrib import admin
from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizCategory, QuizImage from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizCategory, QuizImage, QuizRating
# Für das QivipQuiz Modell # Für das QivipQuiz Modell
class QivipQuizAdmin(admin.ModelAdmin): class QivipQuizAdmin(admin.ModelAdmin):
list_display = ('name', 'user_id', 'status', 'category', 'creation_date', 'update_date') # Welche Felder sollen in der Übersicht angezeigt werden list_display = ('name','published_at', '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

View File

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

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

@@ -3,7 +3,7 @@ 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): 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/ image = models.ImageField(max_length=256,verbose_name="Bild", upload_to='quiz_images/', blank=True, null=True) # Bild speichern in media/quiz_images/
@@ -59,6 +59,10 @@ class QivipQuiz(models.Model):
credits=models.TextField(blank=True, editable=True) credits=models.TextField(blank=True, editable=True)
average_rating = models.FloatField(default=3.0) average_rating = models.FloatField(default=3.0)
rating_count = models.IntegerField(default=0) rating_count = models.IntegerField(default=0)
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
@@ -111,9 +115,22 @@ class QuizRating(models.Model):
rating = models.IntegerField(choices=[(i, str(i)) for i in range(1, 6)]) rating = models.IntegerField(choices=[(i, str(i)) for i in range(1, 6)])
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class Meta: #class Meta:
unique_together = ('quiz', 'participant_id') #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): def save(self, *args, **kwargs):
is_new = self.pk is None is_new = self.pk is None
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -125,7 +142,7 @@ class QuizRating(models.Model):
avg_rating = quiz.ratings.aggregate(models.Avg('rating'))['rating__avg'] avg_rating = quiz.ratings.aggregate(models.Avg('rating'))['rating__avg']
quiz.average_rating = round(avg_rating, 1) if avg_rating else 3.0 quiz.average_rating = round(avg_rating, 1) if avg_rating else 3.0
quiz.rating_count = total_ratings quiz.rating_count = total_ratings
quiz.save() 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)

View File

@@ -320,6 +320,7 @@ def favorite_quiz(request, pk):
if favorite.favorite==True: if favorite.favorite==True:
favorite.favorite_date = timezone.now() favorite.favorite_date = timezone.now()
favorite.save() favorite.save()
@@ -347,10 +348,19 @@ def new_quiz(request):
form = QuizForm(request.POST, request.FILES) form = QuizForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
quiz = form.save(commit=False) quiz = form.save(commit=False)
status = form.cleaned_data.get('status')
if status == 'öffentlich' and not quiz.published_at:
quiz.published_at = timezone.now()
try: try:
quiz_image=request.FILES['quiz_image'] quiz_image=request.FILES['quiz_image']
imagequiz=QuizImage.objects.create(image=quiz_image) imagequiz=QuizImage.objects.create(image=quiz_image)
quiz.image=imagequiz quiz.image=imagequiz
@@ -359,6 +369,8 @@ def new_quiz(request):
quiz.user_id = request.user quiz.user_id = request.user
#quiz.creator = request.user #quiz.creator = request.user
quiz.save() quiz.save()
#form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags) #form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags)
@@ -374,8 +386,13 @@ 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, files=request.FILES)
if form.is_valid(): if form.is_valid():
if 'reset_image' in request.POST: if 'reset_image' in request.POST:
quiz.image = None quiz.image = None
@@ -388,7 +405,12 @@ def edit_quiz(request, pk):
except: except:
pass 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(): for img in QuizImage.objects.all():
@@ -466,6 +488,7 @@ def copy_quiz(request, pk):
new_quiz.base_quiz_id = pk new_quiz.base_quiz_id = pk
new_quiz.rating_count = 0 new_quiz.rating_count = 0
new_quiz.average_rating = 3.0 new_quiz.average_rating = 3.0
new_quiz.published_at = timezone.now()
new_quiz.save() # Speichern als neues Objekt new_quiz.save() # Speichern als neues Objekt
# Optional: Beschreibung anpassen # Optional: Beschreibung anpassen
# Alle zugehörigen Fragen kopieren # Alle zugehörigen Fragen kopieren

View File

@@ -264,6 +264,7 @@ class GameConsumer(AsyncWebsocketConsumer):
@database_sync_to_async @database_sync_to_async
def save_rating(self, participant_id, rating): def save_rating(self, participant_id, rating):
from django.db import models
if not settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']: if not settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']:
return False return False
from library.models import QuizRating from library.models import QuizRating
@@ -275,16 +276,23 @@ class GameConsumer(AsyncWebsocketConsumer):
) )
# Update or create rating # Update or create rating
"""
rating_obj, created = QuizRating.objects.get_or_create( rating_obj, created = QuizRating.objects.get_or_create(
quiz=game.quiz_id, quiz=game.quiz_id,
participant_id=participant_id, participant_id=participant_id,
defaults={'rating': rating} defaults={'rating': rating}
) )
"""
rating_obj=QuizRating.objects.create(
quiz=game.quiz_id,
participant_id=participant_id,
rating=rating)
if not created:
rating_obj.rating = rating rating_obj.rating = rating
rating_obj.save() rating_obj.save()
return True return True
except (QuizGame.DoesNotExist, QuizGameParticipant.DoesNotExist): except (QuizGame.DoesNotExist, QuizGameParticipant.DoesNotExist):
return False return False

View File

@@ -147,26 +147,7 @@ def join_game(request):
messages.error(request, "Dieses Spiel existiert nicht.") messages.error(request, "Dieses Spiel existiert nicht.")
return render(request, 'play/join_game.html') return render(request, 'play/join_game.html')
def finished(request, join_code):
try:
game = QuizGame.objects.get(join_code=join_code)
participant = None
participant_id = request.session.get('participant_id')
if participant_id:
participant = QuizGameParticipant.objects.filter(
quiz_game=game,
participant_id=participant_id
).first()
context = {
'join_code': join_code,
'is_host': str(game.host_id) == str(request.session.get('host_id')),
'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 scores(request, join_code): def scores(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code) quiz_game = get_object_or_404(QuizGame, join_code=join_code)
@@ -207,8 +188,9 @@ def scores(request, join_code):
'question_index': quiz_game.current_question_index, 'question_index': quiz_game.current_question_index,
'total_questions': len(questions) 'total_questions': len(questions)
}) })
"""
def finished(request, join_code): def finished(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code) quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Verify game state # Verify game state
@@ -240,6 +222,46 @@ def finished(request, join_code):
'is_host': is_host, 'is_host': is_host,
'participant_id': participant_id if participant else None '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): def question(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code) quiz_game = get_object_or_404(QuizGame, join_code=join_code)

View File

@@ -1,6 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% load humanize %}
{% block extra_css %} {% block extra_css %}
<style> <style>
@@ -126,6 +126,8 @@
<span class="text-gray-800">{{ quiz.status }}</span> <span class="text-gray-800">{{ quiz.status }}</span>
</div> </div>
<!-- Rating --> <!-- Rating -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{% if quiz.rating_count > 0 %} {% if quiz.rating_count > 0 %}
@@ -142,20 +144,31 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!--
<!-- Authors --> {% if quiz.user_id %}
{% if quiz.authors.all %}
<div class="flex items-center gap-2 text-sm mt-2"> <div class="flex items-center gap-2 text-sm mt-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg> </svg>
<span class="text-gray-600"> <span class="text-gray-600">
{% for author in quiz.authors.all %} {{ quiz.user_id.username }}
{{ author.username }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</span> </span>
</div> </div>
{% endif %} {% endif %}
-->
<!-- Zeit-->
{% if quiz.published_at != None %}
<div class="flex items-center gap-2 ">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="text-green-400 size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="text-gray-800 text-sm "><span class="font-medium">{{ quiz.published_at |naturaltime }}</span> <span class="text-sm">erst. veröffentlicht</span> </span>
</div>
{% endif %}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3"> <div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">

View File

@@ -1,6 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% load humanize %}
{% block extra_css %} {% block extra_css %}
<style> <style>
@@ -148,6 +148,7 @@
</div> </div>
<!-- Authors --> <!-- Authors -->
<!--
{% if quiz.authors.all %} {% if quiz.authors.all %}
<div class="flex items-center gap-2 text-sm mt-2"> <div class="flex items-center gap-2 text-sm mt-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -159,7 +160,18 @@
{% endfor %} {% endfor %}
</span> </span>
</div> </div>
{% endif %} {% endif %}-->
<!-- Zeit-->
{% if quiz.published_at != None %}
<div class="flex items-center gap-2 ">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="text-green-400 size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="text-gray-800 text-sm "><span class="font-medium">{{ quiz.published_at |naturaltime }}</span> <span class="text-sm">erst. veröffentlicht</span> </span>
</div>
{% endif %}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3"> <div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">

View File

@@ -1,6 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% load humanize %}
{% block extra_css %} {% block extra_css %}
<style> <style>
@@ -145,6 +145,7 @@
</div> </div>
<!-- Authors --> <!-- Authors -->
<!--
{% if quiz.authors.all %} {% if quiz.authors.all %}
<div class="flex items-center gap-2 text-sm mt-2"> <div class="flex items-center gap-2 text-sm mt-2">
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -157,6 +158,19 @@
</span> </span>
</div> </div>
{% endif %} {% endif %}
-->
<!-- Zeit-->
{% if quiz.published_at != None %}
<div class="flex items-center gap-2 ">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="text-green-400 size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
<span class="text-gray-800 text-sm "><span class="font-medium">{{ quiz.published_at |naturaltime }}</span> <span class="text-sm">erst. veröffentlicht</span> </span>
</div>
{% endif %}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3"> <div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">