This commit is contained in:
@@ -20,5 +20,7 @@ urlpatterns = [
|
|||||||
path('detail/copy/<int:pk>/', views.copy_quiz, name='copy_quiz'),
|
path('detail/copy/<int:pk>/', views.copy_quiz, name='copy_quiz'),
|
||||||
path('favorite_quiz/<int:pk>/', views.favorite_quiz, name='favorite_quiz'),
|
path('favorite_quiz/<int:pk>/', views.favorite_quiz, name='favorite_quiz'),
|
||||||
path("quiz-names-json/", views.quiz_names_json, name="quiz_names_json"),
|
path("quiz-names-json/", views.quiz_names_json, name="quiz_names_json"),
|
||||||
|
path('quiz/<int:pk>/pdf/', views.pdf_solution, name='quiz_solution'),
|
||||||
|
path('quiz/<int:pk>/pdf/task', views.pdf_task, name='quiz_task'),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,6 +17,329 @@ from django.contrib.auth.models import User
|
|||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
# Übersicht aller Quizze
|
# Übersicht aller Quizze
|
||||||
|
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from django.http import HttpResponse
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from reportlab.platypus import Image
|
||||||
|
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
import json
|
||||||
|
from io import BytesIO
|
||||||
|
import requests
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from PIL import Image as PILImage, ImageOps
|
||||||
|
from django.conf import settings
|
||||||
|
import os
|
||||||
|
from django.utils.html import escape
|
||||||
|
|
||||||
|
def pdf_solution(request, pk):
|
||||||
|
quiz = get_object_or_404(QivipQuiz, pk=pk)
|
||||||
|
|
||||||
|
questions = QivipQuestion.objects.filter(quiz_id=quiz)
|
||||||
|
|
||||||
|
for question in questions:
|
||||||
|
if question.data:
|
||||||
|
question.data = json.loads(question.data)
|
||||||
|
|
||||||
|
response = HttpResponse(content_type='application/pdf')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="Lösungen - {quiz.name}.pdf"'
|
||||||
|
|
||||||
|
doc = SimpleDocTemplate(response, pagesize=A4,
|
||||||
|
rightMargin=40, leftMargin=40,
|
||||||
|
topMargin=60, bottomMargin=60)
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
# Stil für Quiz-Titel
|
||||||
|
title_style = ParagraphStyle(
|
||||||
|
'TitleStyle',
|
||||||
|
parent=styles['Title'],
|
||||||
|
alignment=1, # zentriert
|
||||||
|
fontSize=14,
|
||||||
|
spaceAfter=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stil für Fragen-Box
|
||||||
|
question_box_style = ParagraphStyle(
|
||||||
|
'QuestionBox',
|
||||||
|
fontSize=10,
|
||||||
|
leading=16,
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
|
||||||
|
|
||||||
|
spaceAfter=8,
|
||||||
|
leftIndent=6,
|
||||||
|
rightIndent=6,
|
||||||
|
|
||||||
|
alignment=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stil für Antwort (richtig)
|
||||||
|
answer_correct_style = ParagraphStyle(
|
||||||
|
'AnswerCorrect',
|
||||||
|
fontSize=11,
|
||||||
|
leading=14,
|
||||||
|
textColor=colors.green,
|
||||||
|
leftIndent=20,
|
||||||
|
spaceAfter=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stil für Antwort (falsch)
|
||||||
|
answer_wrong_style = ParagraphStyle(
|
||||||
|
'AnswerWrong',
|
||||||
|
fontSize=11,
|
||||||
|
leading=14,
|
||||||
|
textColor=colors.red,
|
||||||
|
leftIndent=20,
|
||||||
|
spaceAfter=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stil für normalen Antwort-Text (ohne Antwortanzeige)
|
||||||
|
answer_neutral_style = ParagraphStyle(
|
||||||
|
'AnswerNeutral',
|
||||||
|
fontSize=11,
|
||||||
|
leading=14,
|
||||||
|
textColor=colors.black,
|
||||||
|
leftIndent=20,
|
||||||
|
spaceAfter=4,
|
||||||
|
)
|
||||||
|
date_style = ParagraphStyle(
|
||||||
|
name='RightAligned',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
alignment=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
story = []
|
||||||
|
|
||||||
|
story.append(Paragraph(f"Quiz: {quiz.name}", title_style))
|
||||||
|
story.append(Paragraph(f"- Lösungen -", title_style))
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
story.append(Paragraph(datetime.today().strftime("%d.%m.%Y"), date_style))
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
if quiz.description:
|
||||||
|
story.append(Paragraph(quiz.description, styles['Normal']))
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for i, question in enumerate(questions, start=1):
|
||||||
|
data = question.data
|
||||||
|
frage_text = data.get('question', 'Frage nicht gefunden.')
|
||||||
|
|
||||||
|
story.append(Paragraph(f"Frage {i}: {frage_text}", question_box_style))
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
|
||||||
|
rl_image = None
|
||||||
|
if hasattr(question, 'image') and question.image and question.image.image:
|
||||||
|
image_url = request.build_absolute_uri(question.image.image.url)
|
||||||
|
img_response = requests.get(image_url)
|
||||||
|
if img_response.status_code == 200:
|
||||||
|
image_data = BytesIO(img_response.content)
|
||||||
|
pil_image = PILImage.open(image_data)
|
||||||
|
pil_image = ImageOps.exif_transpose(pil_image)
|
||||||
|
orig_width, orig_height = pil_image.size
|
||||||
|
max_width = 80
|
||||||
|
max_height = 80
|
||||||
|
ratio = min(max_width / orig_width, max_height / orig_height)
|
||||||
|
new_width = orig_width * ratio
|
||||||
|
new_height = orig_height * ratio
|
||||||
|
output = BytesIO()
|
||||||
|
pil_image.save(output, format='JPEG')
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
rl_image = Image(output, width=new_width, height=new_height)
|
||||||
|
|
||||||
|
options = data.get('options', [])
|
||||||
|
answer_paragraphs = []
|
||||||
|
for idx, option in enumerate(options, start=1):
|
||||||
|
opt_text = option.get('value', '')
|
||||||
|
if option.get('is_correct', False):
|
||||||
|
text = f"✔ {opt_text}"
|
||||||
|
style = answer_correct_style
|
||||||
|
else:
|
||||||
|
text = f"✘ {opt_text}"
|
||||||
|
style = answer_wrong_style
|
||||||
|
answer_paragraphs.append(Paragraph(text, style))
|
||||||
|
|
||||||
|
if rl_image:
|
||||||
|
# Tabelle mit Bild links und Antworten rechts
|
||||||
|
from reportlab.platypus import Table, TableStyle
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
data_table = [[rl_image, answer_paragraphs]]
|
||||||
|
table = Table(data_table, colWidths=[6*cm, 10*cm])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0,0), (-1,-1), 'TOP'),
|
||||||
|
('LEFTPADDING', (0,0), (-1,-1), 0),
|
||||||
|
('RIGHTPADDING', (0,0), (-1,-1), 10),
|
||||||
|
]))
|
||||||
|
story.append(table)
|
||||||
|
else:
|
||||||
|
# Kein Bild, Antworten einfach nacheinander
|
||||||
|
for p in answer_paragraphs:
|
||||||
|
story.append(p)
|
||||||
|
|
||||||
|
story.append(Spacer(1, 12))
|
||||||
|
story.append(HRFlowable(width="100%", thickness=1, color=colors.grey))
|
||||||
|
story.append(Spacer(1, 12))
|
||||||
|
current_url = request.build_absolute_uri(f"/library/detail/{quiz.pk}/")
|
||||||
|
escaped_url = escape(current_url) # Sicherheitshalber escapen
|
||||||
|
|
||||||
|
link_html = f'<a href="{escaped_url}" color="blue">{escaped_url}</a>'
|
||||||
|
story.append(Paragraph(link_html, styles['Normal']))
|
||||||
|
|
||||||
|
|
||||||
|
doc.build(story)
|
||||||
|
return response
|
||||||
|
def pdf_task(request, pk):
|
||||||
|
quiz = get_object_or_404(QivipQuiz, pk=pk)
|
||||||
|
questions = QivipQuestion.objects.filter(quiz_id=quiz)
|
||||||
|
|
||||||
|
for question in questions:
|
||||||
|
if question.data:
|
||||||
|
question.data = json.loads(question.data)
|
||||||
|
|
||||||
|
response = HttpResponse(content_type='application/pdf')
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="Aufgabenblatt - {quiz.name}.pdf"'
|
||||||
|
|
||||||
|
doc = SimpleDocTemplate(response, pagesize=A4,
|
||||||
|
rightMargin=40, leftMargin=40,
|
||||||
|
topMargin=60, bottomMargin=60)
|
||||||
|
|
||||||
|
styles = getSampleStyleSheet()
|
||||||
|
|
||||||
|
title_style = ParagraphStyle(
|
||||||
|
'TitleStyle',
|
||||||
|
parent=styles['Title'],
|
||||||
|
alignment=1,
|
||||||
|
fontSize=14,
|
||||||
|
spaceAfter=2,
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
)
|
||||||
|
|
||||||
|
question_box_style = ParagraphStyle(
|
||||||
|
'QuestionBox',
|
||||||
|
fontSize=10,
|
||||||
|
leading=16,
|
||||||
|
fontName='Helvetica-Bold',
|
||||||
|
spaceAfter=8,
|
||||||
|
leftIndent=6,
|
||||||
|
rightIndent=6,
|
||||||
|
alignment=0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
answer_neutral_style = ParagraphStyle(
|
||||||
|
'AnswerNeutral',
|
||||||
|
fontSize=11,
|
||||||
|
leading=14,
|
||||||
|
textColor=colors.black,
|
||||||
|
leftIndent=20,
|
||||||
|
spaceAfter=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
date_style = ParagraphStyle(
|
||||||
|
name='RightAligned',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
alignment=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
name_style = ParagraphStyle(
|
||||||
|
name='RightAligned',
|
||||||
|
parent=styles['Normal'],
|
||||||
|
alignment=0,
|
||||||
|
)
|
||||||
|
story = []
|
||||||
|
|
||||||
|
# Titel & Datum
|
||||||
|
story.append(Paragraph(f"Aufgabenblatt: {quiz.name}", title_style))
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
story.append(Paragraph(datetime.today().strftime("%d.%m.%Y"), date_style))
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
story.append(Paragraph("Name: ", name_style))
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
if quiz.description:
|
||||||
|
story.append(Paragraph(quiz.description, styles['Normal']))
|
||||||
|
story.append(Spacer(1, 20))
|
||||||
|
|
||||||
|
for i, question in enumerate(questions, start=1):
|
||||||
|
data = question.data
|
||||||
|
frage_text = data.get('question', 'Frage nicht gefunden.')
|
||||||
|
story.append(Paragraph(f"Frage {i}: {frage_text}", question_box_style))
|
||||||
|
story.append(Spacer(1, 6))
|
||||||
|
|
||||||
|
rl_image = None
|
||||||
|
if hasattr(question, 'image') and question.image and question.image.image:
|
||||||
|
image_url = request.build_absolute_uri(question.image.image.url)
|
||||||
|
img_response = requests.get(image_url)
|
||||||
|
if img_response.status_code == 200:
|
||||||
|
image_data = BytesIO(img_response.content)
|
||||||
|
pil_image = PILImage.open(image_data)
|
||||||
|
pil_image = ImageOps.exif_transpose(pil_image)
|
||||||
|
orig_width, orig_height = pil_image.size
|
||||||
|
max_width = 80 # gleich wie bei Lösungen
|
||||||
|
max_height = 80
|
||||||
|
ratio = min(max_width / orig_width, max_height / orig_height)
|
||||||
|
new_width = orig_width * ratio
|
||||||
|
new_height = orig_height * ratio
|
||||||
|
output = BytesIO()
|
||||||
|
pil_image.save(output, format='JPEG')
|
||||||
|
output.seek(0)
|
||||||
|
rl_image = Image(output, width=new_width, height=new_height)
|
||||||
|
|
||||||
|
options = data.get('options', [])
|
||||||
|
|
||||||
|
answer_paragraphs = []
|
||||||
|
for idx, option in enumerate(options, start=1):
|
||||||
|
|
||||||
|
opt_text = option.get('value', '')
|
||||||
|
if data.get("type") !="input":
|
||||||
|
opt_text = option.get('value', '')
|
||||||
|
answer_paragraphs.append(Paragraph(f"{idx}. {opt_text}", answer_neutral_style))
|
||||||
|
else:
|
||||||
|
story.append(Paragraph("Deine Antwort: ", answer_neutral_style))
|
||||||
|
story.append(Spacer(1, 12))
|
||||||
|
|
||||||
|
if rl_image:
|
||||||
|
from reportlab.platypus import Table, TableStyle
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
data_table = [[rl_image, answer_paragraphs]]
|
||||||
|
table = Table(data_table, colWidths=[6*cm, 10*cm])
|
||||||
|
table.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0,0), (-1,-1), 'TOP'),
|
||||||
|
('LEFTPADDING', (0,0), (-1,-1), 0),
|
||||||
|
('RIGHTPADDING', (0,0), (-1,-1), 10),
|
||||||
|
]))
|
||||||
|
story.append(table)
|
||||||
|
else:
|
||||||
|
for p in answer_paragraphs:
|
||||||
|
story.append(p)
|
||||||
|
|
||||||
|
story.append(Spacer(1, 12))
|
||||||
|
story.append(HRFlowable(width="100%", thickness=1, color=colors.grey))
|
||||||
|
story.append(Spacer(1, 12))
|
||||||
|
current_url = request.build_absolute_uri(f"/library/detail/{quiz.pk}/")
|
||||||
|
escaped_url = escape(current_url) # Sicherheitshalber escapen
|
||||||
|
|
||||||
|
link_html = f'<a href="{escaped_url}" color="blue">{escaped_url}</a>'
|
||||||
|
story.append(Paragraph(link_html, styles['Normal']))
|
||||||
|
doc.build(story)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def quiz_names_json(request):
|
def quiz_names_json(request):
|
||||||
form = QuizFilterForm(request.GET)
|
form = QuizFilterForm(request.GET)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
|
|
||||||
<div class="flex flex-wrap space-y-2 items-center justify-between mb-6 dark:text-white">
|
<div class="flex flex-wrap space-y-2 items-center justify-between mb-6 dark:text-white">
|
||||||
<h1 class="text-2xl font-bold mr-4 dark:text-white ">{{ quiz.name }}</h1>
|
<h1 class="text-2xl font-bold mr-4 dark:text-white ">{{ quiz.name }}</h1>
|
||||||
|
|
||||||
@@ -50,9 +49,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if quiz.description %}
|
{% if quiz.description %}
|
||||||
<p class="text-gray-600 mb-4 dark:text-gray-400">{{ quiz.description }}</p>
|
<p class="text-gray-600 mb-4 dark:text-gray-400">{{ quiz.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -253,6 +253,38 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if questions %}
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center my-6">
|
||||||
|
|
||||||
|
<!-- Aufgabenblatt herunterladen Button -->
|
||||||
|
|
||||||
|
<a href="{% url 'library:quiz_task' pk=quiz.pk %}?show_answers=true"
|
||||||
|
target="_blank"
|
||||||
|
class=" inline-flex items-center gap-2 px-2 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white shadow-md transition">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
als Aufgabenblatt herunterladen (PDF)
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Lösung herunterladen Button -->
|
||||||
|
<a href="{% url 'library:quiz_solution' pk=quiz.pk %}?show_answers=true"
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center gap-2 px-2 py-2 rounded-lg bg-green-600 hover:bg-green-700 text-white shadow-md transition">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none"
|
||||||
|
viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Lösungen herunterladen (PDF)
|
||||||
|
</a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="flex flex-col max-w-full">
|
<div class="flex flex-col max-w-full">
|
||||||
<p class="text-gray-600 mt-2 dark:text-white ">Credits: </p>
|
<p class="text-gray-600 mt-2 dark:text-white ">Credits: </p>
|
||||||
{% if quiz.credits %}
|
{% if quiz.credits %}
|
||||||
@@ -266,6 +298,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="flex flex-col max-w-full">
|
<div class="flex flex-col max-w-full">
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{% regroup questions by credits as grouped_credits %}
|
{% regroup questions by credits as grouped_credits %}
|
||||||
@@ -284,7 +318,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ django-cleanup
|
|||||||
redis
|
redis
|
||||||
channels_redis
|
channels_redis
|
||||||
python-dotenv # Production server safety
|
python-dotenv # Production server safety
|
||||||
qrcode~=7.4.2
|
qrcode~=7.4.2
|
||||||
|
reportlab~=4.2.0
|
||||||
Reference in New Issue
Block a user