Merge branch '146-pdf-erstellen-fur-quiz' into 'master'
Resolve "PDF - erstellen für Quiz" Closes #146 See merge request enrichment-2024/qivip!102
This commit is contained in:
@@ -20,5 +20,7 @@ urlpatterns = [
|
||||
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"),
|
||||
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
|
||||
# Ü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):
|
||||
form = QuizFilterForm(request.GET)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
{% block content %}
|
||||
|
||||
<div class="container mx-auto px-4">
|
||||
|
||||
<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>
|
||||
|
||||
@@ -51,6 +50,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% if quiz.description %}
|
||||
@@ -253,6 +253,38 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
<p class="text-gray-600 mt-2 dark:text-white ">Credits: </p>
|
||||
{% if quiz.credits %}
|
||||
@@ -266,6 +298,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="flex flex-col max-w-full">
|
||||
<ul class="space-y-2">
|
||||
{% regroup questions by credits as grouped_credits %}
|
||||
@@ -284,7 +318,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,3 +9,4 @@ redis
|
||||
channels_redis
|
||||
python-dotenv # Production server safety
|
||||
qrcode~=7.4.2
|
||||
reportlab~=4.2.0
|
||||
Reference in New Issue
Block a user