PDF generiert

This commit is contained in:
Bernhard Kolb 2026-04-13 22:58:43 +02:00
parent 8a9823e9de
commit 470a8b1881
14 changed files with 496 additions and 18 deletions

View File

@ -2,6 +2,17 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# 👉 SYSTEM LIBRARIES für WeasyPrint
RUN apt-get update && apt-get install -y \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libcairo2 \
libgdk-pixbuf-2.0-0 \
libffi-dev \
shared-mime-info \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

BIN
Kurzanleitung.docx Normal file

Binary file not shown.

91
app.py
View File

@ -1,5 +1,5 @@
from pathlib import Path from pathlib import Path
from flask import Flask, flash, redirect, render_template, request, session, url_for, send_from_directory from flask import Flask, flash, redirect, render_template, request, session, url_for, send_from_directory, abort
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from config import Config from config import Config
@ -52,10 +52,15 @@ from db import (
get_thema_for_branche, get_thema_for_branche,
get_question_count_for_thema, get_question_count_for_thema,
get_all_themen_with_question_count, get_all_themen_with_question_count,
get_pdf_recommendation_topics,
get_random_ansprechpartner_for_thema,
create_empfehlung,
) )
from permissions import admin_required, login_required from permissions import admin_required, login_required
from tools import create_assessment_chart, generate_activation_token, send_mail, verify_activation_token from tools import create_assessment_chart, generate_activation_token, send_mail, verify_activation_token, generate_pdf_from_html
from datetime import datetime
from pathlib import Path
from copy import deepcopy
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
@ -63,6 +68,8 @@ app.config.from_object(Config)
chart_dir = Path("generated_charts") chart_dir = Path("generated_charts")
chart_dir.mkdir(exist_ok=True) chart_dir.mkdir(exist_ok=True)
pdf_dir = Path("generated_pdfs")
pdf_dir.mkdir(exist_ok=True)
@app.context_processor @app.context_processor
def inject_user(): def inject_user():
@ -314,26 +321,80 @@ def assessment_result(assessment_id):
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
rows = get_assessment_result_rows(assessment_id, branche_id) rows = get_assessment_result_rows(assessment_id, branche_id)
if not rows: if not rows:
flash("Für diese Branche konnten keine Auswertungsdaten geladen werden.", "warning") flash("Für diese Branche konnten keine Auswertungsdaten geladen werden.", "warning")
return redirect(url_for("dashboard")) return redirect(url_for("dashboard"))
branche = get_branche_by_id(branche_id)
user = get_user_by_id(session["user_id"])
labels = [row["kurztitel"] for row in rows] labels = [row["kurztitel"] for row in rows]
values = [int(row["ja_anzahl"]) for row in rows] values = [int(row["ja_anzahl"]) for row in rows]
filename = f"assessment_{assessment_id}_branche_{branche_id}.png" chart_filename = f"assessment_{assessment_id}_branche_{branche_id}.png"
output_path = chart_dir / filename chart_path = chart_dir / chart_filename
create_assessment_chart(labels, values, output_path) create_assessment_chart(labels, values, chart_path)
today_str = datetime.now().strftime("%d.%m.%Y")
logo_up_path = (Path(app.root_path) / "static" / "pdf" / "logo_up.png").resolve().as_uri()
unternehmer_path = (Path(app.root_path) / "static" / "pdf" / "unternehmer.png").resolve().as_uri()
chart_pdf_path = chart_path.resolve().as_uri()
today_str = datetime.now().strftime("%d.%m.%Y")
pdf_filename = f"assessment_{assessment_id}_branche_{branche_id}.pdf"
pdf_path = pdf_dir / pdf_filename
recommendation_topics = get_pdf_recommendation_topics(assessment_id, branche_id)
pdf_recommendations = []
for topic in recommendation_topics:
topic_copy = deepcopy(topic)
ansprechpartner = get_random_ansprechpartner_for_thema(topic["id"])
topic_copy["ansprechpartner"] = ansprechpartner
pdf_recommendations.append(topic_copy)
if ansprechpartner:
create_empfehlung(
user_id=session["user_id"],
thema_id=topic["id"],
ansprechpartner_id=ansprechpartner["id"],
)
pdf_html = render_template(
"pdf/assessment_report.html",
assessment_id=assessment_id,
branche=branche,
user=user,
today_str=today_str,
rows=rows,
pdf_recommendations=pdf_recommendations,
chart_pdf_path=chart_pdf_path,
logo_up_path=logo_up_path,
unternehmer_path=unternehmer_path,
)
generate_pdf_from_html(
html_string=pdf_html,
output_path=pdf_path,
base_url=request.url_root.rstrip("/") + "/",
)
return render_template( return render_template(
"result.html", "result.html",
assessment_id=assessment_id, assessment_id=assessment_id,
branche_id=branche_id, branche_id=branche_id,
chart_file=filename, chart_file=chart_filename,
pdf_file=pdf_filename,
rows=rows, rows=rows,
) )
@app.route("/generated_pdfs/<path:filename>")
@login_required
def generated_pdf(filename):
return send_from_directory(pdf_dir, filename, as_attachment=True)
@app.route("/generated_charts/<path:filename>") @app.route("/generated_charts/<path:filename>")
@login_required @login_required
@ -839,5 +900,19 @@ def admin_question_edit_for_thema(thema_id, frage_id):
min_questions=8, min_questions=8,
) )
@app.errorhandler(401)
def unauthorized_error(error):
return render_template("401.html"), 401
@app.errorhandler(404)
def not_found_error(error):
return render_template("404.html"), 404
@app.errorhandler(500)
def internal_error(error):
return render_template("500.html"), 500
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)

View File

@ -9,11 +9,11 @@ class Config:
DB_PASSWORD = os.getenv('DB_PASSWORD', 'UnternehmenPWD') DB_PASSWORD = os.getenv('DB_PASSWORD', 'UnternehmenPWD')
DB_PORT = int(os.getenv('DB_PORT', '5432')) DB_PORT = int(os.getenv('DB_PORT', '5432'))
SMTP_SERVER = os.getenv('SMTP_SERVER', 'mail.kolb.cc') SMTP_SERVER = os.getenv("SMTP_SERVER")
SMTP_PORT = int(os.getenv('SMTP_PORT', '25')) SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USERNAME = os.getenv('SMTP_USERNAME', '') SMTP_USERNAME = os.getenv("SMTP_USERNAME")
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', '') SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
MAIL_SENDER = os.getenv('MAIL_SENDER', 'admin@kolb.cc') MAIL_SENDER = os.getenv("MAIL_SENDER")
MAIL_USE_TLS = os.getenv('MAIL_USE_TLS', 'false').lower() == 'true' MAIL_USE_TLS = os.getenv("MAIL_USE_TLS", "true").lower() == "true"
APP_BASE_URL = os.getenv('APP_BASE_URL', 'https://test.dasunternehmen.com') APP_BASE_URL = os.getenv('APP_BASE_URL', 'https://test.dasunternehmen.com')

48
db.py
View File

@ -413,6 +413,7 @@ def get_assessment_result_rows(assessment_id, branche_id):
SELECT SELECT
t.id, t.id,
t.kurztitel, t.kurztitel,
t.titel,
COUNT(*) FILTER (WHERE aa.antwort = TRUE) AS ja_anzahl COUNT(*) FILTER (WHERE aa.antwort = TRUE) AS ja_anzahl
FROM thema t FROM thema t
JOIN branchenthemen bt JOIN branchenthemen bt
@ -421,7 +422,7 @@ def get_assessment_result_rows(assessment_id, branche_id):
ON aa.thema_id = t.id ON aa.thema_id = t.id
AND aa.assessmentid = %s AND aa.assessmentid = %s
WHERE bt.branche_id = %s WHERE bt.branche_id = %s
GROUP BY t.id, t.kurztitel GROUP BY t.id, t.kurztitel, t.titel
ORDER BY t.id ORDER BY t.id
""", """,
(assessment_id, branche_id), (assessment_id, branche_id),
@ -760,3 +761,48 @@ def get_all_themen_with_question_count():
ORDER BY t.id ORDER BY t.id
""" """
) )
def get_random_ansprechpartner_for_thema(thema_id):
return fetch_one(
"""
SELECT a.id, a.name, a.email, a.infotext
FROM ansprechpartner a
JOIN themaansprechpartner ta ON ta.ansprechpartner_id = a.id
WHERE ta.thema_id = %s
ORDER BY RANDOM()
LIMIT 1
""",
(thema_id,),
)
def create_empfehlung(user_id, thema_id, ansprechpartner_id):
return execute_returning(
"""
INSERT INTO empfehlung (user_id, thema_id, ansprechpartner_id)
VALUES (%s, %s, %s)
RETURNING id
""",
(user_id, thema_id, ansprechpartner_id),
)
def get_pdf_recommendation_topics(assessment_id, branche_id):
return fetch_all(
"""
SELECT
t.id,
t.titel,
t.zusatztext,
COUNT(*) FILTER (WHERE aa.antwort = TRUE) AS ja_anzahl
FROM thema t
JOIN branchenthemen bt
ON bt.thema_id = t.id
LEFT JOIN assessmentanswer aa
ON aa.thema_id = t.id
AND aa.assessmentid = %s
WHERE bt.branche_id = %s
GROUP BY t.id, t.titel, t.zusatztext
HAVING COUNT(*) FILTER (WHERE aa.antwort = TRUE) < 7
ORDER BY t.id
""",
(assessment_id, branche_id),
)

View File

@ -4,3 +4,13 @@ Werkzeug==3.1.3
itsdangerous==2.2.0 itsdangerous==2.2.0
matplotlib==3.10.1 matplotlib==3.10.1
gunicorn==23.0.0 gunicorn==23.0.0
weasyprint==60.2
pydyf==0.8.0
cairocffi==1.6.1
Pillow==10.4.0
qrcode==7.4.2
pytest==8.3.2
python-json-logger==2.0.7

BIN
static/pdf/logo_up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

BIN
static/pdf/unternehmer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

17
templates/401.html Normal file
View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}401 - Nicht autorisiert{% endblock %}
{% block content %}
<div class="content-card">
<h1>401 - Nicht autorisiert</h1>
<p>
Sie sind für diese Seite nicht autorisiert oder Ihre Anmeldung ist nicht mehr gültig.
</p>
<div class="form-actions" style="margin-top: 24px;">
<a class="btn" href="{{ url_for('login') }}">Zum Login</a>
<a class="btn btn-secondary" href="{{ url_for('index') }}">Zur Startseite</a>
</div>
</div>
{% endblock %}

19
templates/404.html Normal file
View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}404 - Seite nicht gefunden{% endblock %}
{% block content %}
<div class="content-card">
<h1>404 - Seite nicht gefunden</h1>
<p>
Die angeforderte Seite wurde nicht gefunden.
</p>
<div class="form-actions" style="margin-top: 24px;">
<a class="btn" href="{{ url_for('index') }}">Zur Startseite</a>
{% if current_user %}
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Zum Dashboard</a>
{% endif %}
</div>
</div>
{% endblock %}

19
templates/500.html Normal file
View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}500 - Interner Serverfehler{% endblock %}
{% block content %}
<div class="content-card">
<h1>500 - Interner Serverfehler</h1>
<p>
Es ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es später erneut.
</p>
<div class="form-actions" style="margin-top: 24px;">
<a class="btn" href="{{ url_for('index') }}">Zur Startseite</a>
{% if current_user %}
<a class="btn btn-secondary" href="{{ url_for('dashboard') }}">Zum Dashboard</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Selbsteinschätzung</title>
<style>
@page {
size: A4;
margin: 20mm 15mm 22mm 15mm;
@bottom-center {
content: "Seite " counter(page) " / " counter(pages);
font-size: 10pt;
color: #6b5a4d;
}
}
body {
font-family: Arial, Helvetica, sans-serif;
color: #3c332c;
font-size: 11pt;
line-height: 1.45;
padding-right: 100px; /* Platz für Logo reservieren */
}
.logo-top-right {
position: fixed;
top: 12mm;
right: 5mm;
width: 75px;
opacity: 0.95;
}
.header {
text-align: center;
margin-top: 10px;
margin-bottom: 24px;
}
.header h1 {
margin: 0 0 16px 0;
font-size: 22pt;
color: #8f6437;
}
.user-name {
font-size: 16pt;
font-weight: bold;
margin-bottom: 8px;
}
.date {
font-size: 11pt;
color: #786a5d;
}
.section {
border: 1px solid #eadbc8;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 18px;
background: #fffaf4;
page-break-inside: avoid;
break-inside: avoid;
}
.section h2 {
margin-top: 0;
color: #8f6437;
font-size: 14pt;
}
.chart {
width: 100%;
max-width: 100%;
border: 1px solid #eadbc8;
border-radius: 8px;
display: block;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
th,
td {
padding: 10px 8px;
border-bottom: 1px solid #eadbc8;
}
th {
background: #f7f1e8;
text-align: left;
}
.col-center {
text-align: center;
width: 130px;
}
.recommendation-block {
margin-bottom: 16px;
padding: 14px 16px;
border: 1px solid #eadbc8;
border-radius: 12px;
background: #fff;
page-break-inside: avoid;
break-inside: avoid;
}
.recommendation-block h3 {
margin: 0 0 8px 0;
color: #8f6437;
font-size: 12.5pt;
}
.final-image-wrapper {
text-align: center;
margin-top: 22px;
page-break-inside: avoid;
break-inside: avoid;
page-break-before: auto;
}
.final-image {
width: 40%;
max-width: 410px;
display: inline-block;
}
.closing-block {
margin-top: 28px;
padding: 18px 22px;
border: 1px solid #eadbc8;
border-radius: 18px;
background: linear-gradient(180deg, #fffaf4 0%, #f8efe2 100%);
text-align: center;
page-break-inside: avoid;
break-inside: avoid;
}
.closing-block .closing-headline {
font-size: 16pt;
font-weight: 700;
color: #8f6437;
margin-bottom: 10px;
}
.closing-block .closing-text {
font-size: 12pt;
line-height: 1.5;
margin: 0 auto 10px auto;
max-width: 90%;
}
.closing-block .closing-link {
font-weight: 700;
color: #8f6437;
text-decoration: none;
}
.closing-block .closing-impressum {
margin-top: 10px;
font-size: 10pt;
color: #786a5d;
}
.closing-block .closing-impressum a {
color: #8f6437;
text-decoration: none;
}
</style>
</head>
<body>
<img src="{{ logo_up_path }}" class="logo-top-right" alt="Logo">
<div class="header">
<h1>Selbsteinschätzung für {{ branche.branchenname }}</h1>
<div class="user-name">{{ user.name }}</div>
<div class="date">{{ today_str }}</div>
</div>
<div class="section">
<h2>Grafische Auswertung</h2>
<img src="{{ chart_pdf_path }}" alt="Assessment Chart" class="chart">
</div>
<div class="section">
<h2>Ergebnisübersicht</h2>
<table>
<thead>
<tr>
<th>Thema</th>
<th class="col-center">Anzahl positiver Einschätzungen</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.titel }}</td>
<td class="col-center">{{ row.ja_anzahl }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pdf_recommendations %}
<div class="section">
<h2>Hinweise und Ansprechpartner</h2>
{% for item in pdf_recommendations %}
<div class="recommendation-block">
<h3>{{ item.titel }}</h3>
{% if item.zusatztext %}
<p>{{ item.zusatztext }}</p>
{% endif %}
{% if item.ansprechpartner %}
<p>
<strong>Unser Ansprechpartner für Sie:</strong>
<a href="mailto:{{ item.ansprechpartner.email }}">
{{ item.ansprechpartner.name }}
</a>
</p>
{% else %}
<p>
<strong>Unser Ansprechpartner für Sie:</strong>
aktuell nicht hinterlegt
</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<div class="final-image-wrapper">
<img src="{{ unternehmer_path }}" class="final-image" alt="Unternehmer">
</div>
<div class="closing-block">
<div class="closing-headline">
Ihr Team der Berater von
<a href="https://dasunternehmen.com" class="closing-link">DasUnternehmen.com</a>
</div>
<p class="closing-text">
freut sich darauf, Sie auf Ihrem Weg in eine optimierte Zukunft begleiten zu dürfen.
</p>
<div class="closing-impressum">
<a href="https://dasunternehmen.com/impressum/">Impressum</a>
</div>
</div>
</body>
</html>

View File

@ -18,7 +18,7 @@
<thead> <thead>
<tr> <tr>
<th>Thema</th> <th>Thema</th>
<th>JA-Antworten</th> <th>Positive Einschätzugen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -30,5 +30,12 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div class="form-actions" style="margin-top: 20px;">
<a class="btn"
href="{{ url_for('generated_pdf', filename=pdf_file) }}">
PDF herunterladen
</a>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -8,6 +8,16 @@ matplotlib.use('Agg')
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from config import Config from config import Config
from pathlib import Path
from weasyprint import HTML
def generate_pdf_from_html(html_string, output_path, base_url=None):
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
HTML(string=html_string, base_url=base_url).write_pdf(str(output_path))
return output_path
serializer = URLSafeTimedSerializer(Config.SECRET_KEY) serializer = URLSafeTimedSerializer(Config.SECRET_KEY)
@ -40,10 +50,13 @@ def send_mail(to_address, subject, body):
# Normales SMTP, z. B. Port 25 # Normales SMTP, z. B. Port 25
with smtplib.SMTP(Config.SMTP_SERVER, Config.SMTP_PORT) as server: with smtplib.SMTP(Config.SMTP_SERVER, Config.SMTP_PORT) as server:
server.ehlo()
if Config.MAIL_USE_TLS: if Config.MAIL_USE_TLS:
server.starttls(context=ssl.create_default_context()) server.starttls(context=ssl.create_default_context())
server.ehlo()
if Config.SMTP_USERNAME: if Config.SMTP_USERNAME and Config.SMTP_USERNAME.strip():
server.login(Config.SMTP_USERNAME, Config.SMTP_PASSWORD) server.login(Config.SMTP_USERNAME, Config.SMTP_PASSWORD)
server.send_message(msg) server.send_message(msg)