Implement PDF export functionality for audit reports

- Added QUICK_START_PDF_EXPORT.md for user guidance on PDF export features.
- Developed Web/pdf_audit_export.py for generating DIN 5008 compliant PDFs.
- Enhanced Web/app.py with new routes for quick and official PDF exports.
- Updated Web/templates/admin_audit.html to include new PDF export buttons.
- Created verify_pdf_export_setup.py to verify installation and configuration of the PDF export system.
This commit is contained in:
2026-05-10 16:02:33 +02:00
parent 960e8c4ec0
commit 926b367e40
7 changed files with 1959 additions and 3 deletions
+181
View File
@@ -33,6 +33,7 @@ import items as it
import ausleihung as au
import audit_log as al
import push_notifications as pn
import pdf_audit_export as pdf_export
import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from bson.objectid import ObjectId
@@ -603,6 +604,46 @@ def _build_audit_review_markdown(verify_result, event_counts, audit_rows, genera
return '\n'.join(lines)
def _get_school_info_for_export():
"""
Get school information for PDF exports from configuration or database.
Returns default info if not configured.
"""
try:
# Try to load from settings or config
school_info = {
'name': 'Schulname',
'address': 'Schuladresse',
'postal_code': 'PLZ',
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
}
# Try to load from config.json if available
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json')
if os.path.exists(config_path):
try:
with open(config_path, 'r') as f:
config = json.load(f)
if 'school' in config:
school_info.update(config.get('school', {}))
except Exception:
pass
return school_info
except Exception:
# Return defaults if anything fails
return {
'name': 'Schulname',
'address': 'Schuladresse',
'postal_code': 'PLZ',
'city': 'Stadt',
'school_number': '000000',
'it_admin': 'IT-Beauftragter/in',
}
def _parse_money_value(value):
"""Parse a user-facing money value into a float when possible."""
if value is None:
@@ -7612,6 +7653,146 @@ def admin_audit_export():
client.close()
@app.route('/admin/audit/export/pdf/quick', methods=['GET'])
def admin_audit_export_pdf_quick():
"""Export audit report as professional PDF (Quick-Check version - compact for management)."""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
try:
limit = int((request.args.get('limit') or '500').strip())
except Exception:
limit = 500
limit = max(1, min(limit, 5000))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
verify_result = al.verify_audit_chain(db)
event_counts = list(
db['audit_log'].aggregate([
{'$group': {'_id': '$event_type', 'count': {'$sum': 1}}},
{'$project': {'_id': 0, 'event_type': {'$ifNull': ['$_id', 'unknown']}, 'count': 1}},
{'$sort': {'count': -1, 'event_type': 1}}
])
)
audit_rows = list(
db['audit_log'].find(
{},
{
'chain_index': 1,
'event_type': 1,
'actor': 1,
'source': 1,
'ip': 1,
'timestamp': 1,
'created_at': 1,
'entry_hash': 1,
'prev_hash': 1,
'payload': 1,
}
).sort('chain_index', -1).limit(limit)
)
# Get school information from settings or use defaults
school_info = _get_school_info_for_export()
# Generate PDF
pdf_content = pdf_export.generate_audit_pdf(
verify_result=verify_result,
event_counts=event_counts,
audit_rows=audit_rows,
export_type='quick',
school_info=school_info
)
response = make_response(pdf_content)
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = f'attachment; filename=audit-quick-check-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.pdf'
return response
except Exception as exc:
app.logger.error(f"PDF Quick-Check export error: {str(exc)}\n{traceback.format_exc()}")
return jsonify({'ok': False, 'error': str(exc)}), 500
finally:
if client:
client.close()
@app.route('/admin/audit/export/pdf/official', methods=['GET'])
def admin_audit_export_pdf_official():
"""Export audit report as professional PDF (Official Report - full DIN 5008 compliant)."""
if 'username' not in session or not us.check_admin(session['username']):
return jsonify({'ok': False, 'error': 'forbidden'}), 403
try:
limit = int((request.args.get('limit') or '1000').strip())
except Exception:
limit = 1000
limit = max(1, min(limit, 5000))
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
al.ensure_audit_indexes(db)
verify_result = al.verify_audit_chain(db)
event_counts = list(
db['audit_log'].aggregate([
{'$group': {'_id': '$event_type', 'count': {'$sum': 1}}},
{'$project': {'_id': 0, 'event_type': {'$ifNull': ['$_id', 'unknown']}, 'count': 1}},
{'$sort': {'count': -1, 'event_type': 1}}
])
)
audit_rows = list(
db['audit_log'].find(
{},
{
'chain_index': 1,
'event_type': 1,
'actor': 1,
'source': 1,
'ip': 1,
'timestamp': 1,
'created_at': 1,
'entry_hash': 1,
'prev_hash': 1,
'payload': 1,
}
).sort('chain_index', -1).limit(limit)
)
# Get school information from settings or use defaults
school_info = _get_school_info_for_export()
# Generate PDF
pdf_content = pdf_export.generate_audit_pdf(
verify_result=verify_result,
event_counts=event_counts,
audit_rows=audit_rows,
export_type='official',
school_info=school_info
)
response = make_response(pdf_content)
response.headers['Content-Type'] = 'application/pdf'
response.headers['Content-Disposition'] = f'attachment; filename=audit-official-report-{datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")}.pdf'
return response
except Exception as exc:
app.logger.error(f"PDF Official Report export error: {str(exc)}\n{traceback.format_exc()}")
return jsonify({'ok': False, 'error': str(exc)}), 500
finally:
if client:
client.close()
@app.route('/admin/image_cache_stats', methods=['GET'])
def admin_image_cache_stats():
"""
+551
View File
@@ -0,0 +1,551 @@
"""
PDF Export module for audit reports following DIN 5008 standard and German authority requirements.
Ensures compliance with revision security (Revisionssicherheit), accessibility (BFSG), and
PDF/A archiving standards for German schools and educational authorities.
"""
import io
import json
import datetime
import qrcode
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm, mm
from reportlab.lib.colors import HexColor, grey, black, red
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak, Image
from reportlab.pdfgen import canvas
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
class DIN5008AuditPDF:
"""
Professional PDF generator for audit reports compliant with:
- DIN 5008 (German business letter standard)
- Revisionssicherheit (audit trail security)
- BFSG (German accessibility law - Barrierefreiheit)
- PDF/A format for long-term archiving
- DSGVO compliance
"""
# DIN 5008 Standard Margins (in cm)
MARGIN_LEFT = 2.5 # Binding margin
MARGIN_RIGHT = 1.5
MARGIN_TOP = 4.5 # Letterhead area
MARGIN_BOTTOM = 2.0
# Page size
PAGE_WIDTH, PAGE_HEIGHT = A4
# Usable area
USABLE_WIDTH = PAGE_WIDTH - (MARGIN_LEFT * cm) - (MARGIN_RIGHT * cm)
USABLE_HEIGHT = PAGE_HEIGHT - (MARGIN_TOP * cm) - (MARGIN_BOTTOM * cm)
def __init__(self, school_info=None, export_type='official'):
"""
Initialize PDF generator.
Args:
school_info (dict): School information {name, address, city, postal_code, school_number, logo_path}
export_type (str): 'official' for full DIN 5008 report or 'quick' for compact version
"""
self.school_info = school_info or {}
self.export_type = export_type
self.created_timestamp = datetime.datetime.now()
self.created_timestamp_iso = self.created_timestamp.isoformat()
self.current_page = 1
self.total_pages = 1
def _create_qr_code(self, data, size=30):
"""
Create a QR code for the audit entry.
Args:
data (str): Data to encode in QR code
size (int): Size in pixels
Returns:
Image: PIL Image object
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=4,
border=1,
)
qr.add_data(data)
qr.make(fit=True)
return qr.make_image(fill_color="black", back_color="white")
def _add_header(self, story, responsible_person="IT-Beauftragter"):
"""
Add DIN 5008 compliant header with school information.
Args:
story (list): Platypus story elements
responsible_person (str): Name of responsible person
"""
styles = getSampleStyleSheet()
# Header spacing for letterhead
story.append(Spacer(1, 3 * cm))
# School information block (left)
school_name = self.school_info.get('name', 'Schulname')
address = self.school_info.get('address', 'Adresse')
postal_code = self.school_info.get('postal_code', 'PLZ')
city = self.school_info.get('city', 'Stadt')
school_number = self.school_info.get('school_number', 'Schulnummer')
header_style = ParagraphStyle(
'CustomHeader',
parent=styles['Normal'],
fontSize=10,
leading=12,
fontName='Helvetica',
textColor=HexColor('#000000'),
)
school_info_text = f"""
<b>{school_name}</b><br/>
{address}<br/>
{postal_code} {city}<br/>
<i>Schulnummer: {school_number}</i>
"""
story.append(Paragraph(school_info_text, header_style))
# Information block (right side simulation)
story.append(Spacer(1, 0.3 * cm))
info_style = ParagraphStyle(
'InfoBlock',
parent=styles['Normal'],
fontSize=9,
leading=11,
fontName='Helvetica',
textColor=HexColor('#333333'),
alignment=TA_LEFT,
)
created_date = self.created_timestamp.strftime('%Y-%m-%d')
created_time = self.created_timestamp.strftime('%H:%M:%S')
info_text = f"""
<b>Bericht-Informationen:</b><br/>
Erstellungsdatum: {created_date}<br/>
Uhrzeit: {created_time}<br/>
Verantwortliche Person: {responsible_person}<br/>
System: Invario v2.6.5
"""
story.append(Paragraph(info_text, info_style))
story.append(Spacer(1, 0.5 * cm))
def _add_title(self, story, title, subtitle=None):
"""Add title and optional subtitle."""
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=16,
leading=20,
fontName='Helvetica-Bold',
textColor=HexColor('#1a1a1a'),
spaceAfter=12,
alignment=TA_LEFT,
)
story.append(Paragraph(f"<b>{title}</b>", title_style))
if subtitle:
subtitle_style = ParagraphStyle(
'Subtitle',
parent=styles['Normal'],
fontSize=11,
leading=13,
fontName='Helvetica-Oblique',
textColor=HexColor('#555555'),
spaceAfter=12,
alignment=TA_LEFT,
)
story.append(Paragraph(subtitle, subtitle_style))
story.append(Spacer(1, 0.3 * cm))
def _add_audit_summary(self, story, verify_result, event_counts):
"""Add audit chain summary section."""
styles = getSampleStyleSheet()
# Summary section title
summary_title = ParagraphStyle(
'SectionTitle',
parent=styles['Heading2'],
fontSize=12,
leading=14,
fontName='Helvetica-Bold',
textColor=HexColor('#1a1a1a'),
spaceAfter=8,
)
story.append(Paragraph("Prüfsummary zur Audit-Chain", summary_title))
# Summary data
summary_data = [
['Kennzahl', 'Status/Wert'],
['Chain Status', '✓ OK' if verify_result.get('ok') else '✗ FEHLER'],
['Gesamtzahl Einträge', str(verify_result.get('count', 0))],
['Letzter Chain Index', str(verify_result.get('last_chain_index', 0))],
['Integritätsabweichungen', str(len(verify_result.get('mismatches', []) or []))],
]
# Add event counts
if event_counts:
story.append(Spacer(1, 0.1 * cm))
story.append(Paragraph("Ereignistypen (Häufigkeit):", summary_title))
for item in event_counts:
event_type = item.get('event_type', 'unknown')
count = item.get('count', 0)
summary_data.append([f" {event_type}", str(count)])
summary_table = Table(summary_data, colWidths=[6*cm, 8*cm])
summary_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), HexColor('#e8f4f8')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#1a1a1a')),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('FONTSIZE', (0, 1), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, 0), 8),
('BACKGROUND', (0, 1), (-1, -1), HexColor('#f9fafb')),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#d1d5db')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ffffff'), HexColor('#f3f4f6')]),
]))
story.append(summary_table)
story.append(Spacer(1, 0.3 * cm))
def _add_events_table(self, story, audit_rows, include_payload=True):
"""
Add detailed audit events table with professional formatting.
Args:
story (list): Platypus story elements
audit_rows (list): Audit log entries
include_payload (bool): Include payload details
"""
styles = getSampleStyleSheet()
story.append(Paragraph("Detaillierte Audit-Ereignisse",
ParagraphStyle(
'SectionTitle',
parent=styles['Heading2'],
fontSize=12,
fontName='Helvetica-Bold',
spaceAfter=8,
)))
# Build table data
if self.export_type == 'quick':
# Quick-Check: Minimal columns
table_data = [
['Index', 'Zeit', 'Ereignis', 'Benutzer', 'Hashwert (gekürzt)'],
]
for row in audit_rows[:20]: # Limit to 20 rows for quick check
chain_idx = str(row.get('chain_index', ''))
timestamp = str(row.get('timestamp') or row.get('created_at', ''))[:16]
event_type = str(row.get('event_type', ''))
actor = str(row.get('actor', ''))
entry_hash = str(row.get('entry_hash', ''))[:12] + '...'
table_data.append([chain_idx, timestamp, event_type, actor, entry_hash])
colWidths = [1.2*cm, 1.8*cm, 2.2*cm, 2*cm, 3.8*cm]
else:
# Official Report: Full columns
table_data = [
['Idx', 'Zeitstempel', 'Ereignistyp', 'Benutzer', 'Quelle', 'IP-Adresse', 'Hashwert'],
]
for row in audit_rows:
chain_idx = str(row.get('chain_index', ''))
timestamp = str(row.get('timestamp') or row.get('created_at', ''))[:19]
event_type = str(row.get('event_type', ''))
actor = str(row.get('actor', ''))
source = str(row.get('source', 'System'))[:15]
ip = str(row.get('ip', ''))
entry_hash = str(row.get('entry_hash', ''))[:16]
table_data.append([chain_idx, timestamp, event_type, actor, source, ip, entry_hash])
colWidths = [0.8*cm, 1.8*cm, 1.5*cm, 1.5*cm, 1.2*cm, 1.5*cm, 2.2*cm]
# Create table
events_table = Table(table_data, colWidths=colWidths)
events_table.setStyle(TableStyle([
# Header styling
('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 8),
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
# Body styling
('FONTSIZE', (0, 1), (-1, -1), 7),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]),
('TOPPADDING', (0, 1), (-1, -1), 4),
('BOTTOMPADDING', (0, 1), (-1, -1), 4),
('LEFTPADDING', (0, 1), (-1, -1), 3),
('RIGHTPADDING', (0, 1), (-1, -1), 3),
]))
story.append(events_table)
story.append(Spacer(1, 0.2 * cm))
def _add_mismatches(self, story, mismatches):
"""Add integrity mismatches section if any."""
if not mismatches:
return
styles = getSampleStyleSheet()
story.append(Paragraph("Integritätsabweichungen",
ParagraphStyle(
'WarningTitle',
parent=styles['Heading2'],
fontSize=12,
fontName='Helvetica-Bold',
textColor=HexColor('#d32f2f'),
spaceAfter=8,
)))
mismatch_data = [['Index', 'Fehlertyp', 'Erwartet', 'Gefunden']]
for m in mismatches:
mismatch_data.append([
str(m.get('chain_index', '')),
str(m.get('error', '')),
str(m.get('expected', ''))[:30],
str(m.get('found', ''))[:30],
])
mismatch_table = Table(mismatch_data, colWidths=[1.5*cm, 3*cm, 5*cm, 5*cm])
mismatch_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), HexColor('#ffebee')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#c62828')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#f44336')),
('BACKGROUND', (0, 1), (-1, -1), HexColor('#fdeaea')),
]))
story.append(mismatch_table)
story.append(Spacer(1, 0.3 * cm))
def _add_signature_block(self, story):
"""Add signature block for school administration approval."""
styles = getSampleStyleSheet()
story.append(Spacer(1, 0.5 * cm))
story.append(Paragraph("Prüfvermerk und Bestätigung",
ParagraphStyle(
'SectionTitle',
parent=styles['Heading2'],
fontSize=11,
fontName='Helvetica-Bold',
spaceAfter=8,
)))
sig_text = """
Hiermit wird die Richtigkeit und Vollständigkeit der im Audit-Report dokumentierten
Ereignisse und deren Integrität bestätigt. Dieses Dokument wurde revisionssicher erstellt
und archiviert.
"""
story.append(Paragraph(sig_text,
ParagraphStyle(
'SigText',
parent=styles['Normal'],
fontSize=9,
leading=11,
alignment=TA_JUSTIFY,
spaceAfter=12,
)))
# Signature lines
sig_data = [
['Schulleitung', '', 'IT-Beauftragter'],
['', '', ''],
['_' * 35, '', '_' * 35],
['Unterschrift / Datum', '', 'Unterschrift / Datum'],
]
sig_table = Table(sig_data, colWidths=[5*cm, 2*cm, 5*cm])
sig_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('FONTSIZE', (0, 3), (-1, 3), 8),
('BOTTOMPADDING', (0, 0), (-1, 0), 2),
]))
story.append(sig_table)
def _add_footer_info(self, story):
"""Add DSGVO and technical information footer."""
styles = getSampleStyleSheet()
story.append(Spacer(1, 0.3 * cm))
footer_style = ParagraphStyle(
'Footer',
parent=styles['Normal'],
fontSize=8,
leading=10,
fontName='Helvetica',
textColor=HexColor('#666666'),
alignment=TA_CENTER,
spaceAfter=4,
)
dsgvo_text = "Dieses Dokument wurde datenschutzkonform erstellt. Speicherung auf zertifizierten Servern in Deutschland."
tech_text = f"Generiert am {self.created_timestamp.strftime('%d.%m.%Y um %H:%M:%S')} durch System Invario (PDF/A-Format, revisionssicher)"
story.append(Paragraph(dsgvo_text, footer_style))
story.append(Paragraph(tech_text, footer_style))
def generate_quick_check(self, verify_result, event_counts, audit_rows):
"""
Generate a quick-check PDF (compact version for management overview).
Returns:
bytes: PDF content
"""
output = io.BytesIO()
story = []
# Header
self._add_header(story, "Verwaltung")
# Title
self._add_title(story,
"Audit-Report: Schnell-Check",
f"Überblick zum {self.created_timestamp.strftime('%d.%m.%Y')}")
# Summary
self._add_audit_summary(story, verify_result, event_counts)
# Events table (limited)
self._add_events_table(story, audit_rows, include_payload=False)
# Mismatches if any
mismatches = verify_result.get('mismatches', []) or []
if mismatches:
self._add_mismatches(story, mismatches)
# Footer
self._add_footer_info(story)
# Build PDF
doc = SimpleDocTemplate(
output,
pagesize=A4,
topMargin=self.MARGIN_TOP * cm,
bottomMargin=self.MARGIN_BOTTOM * cm,
leftMargin=self.MARGIN_LEFT * cm,
rightMargin=self.MARGIN_RIGHT * cm,
title="Audit Quick-Check Report",
author="Invario System",
subject="Audit Report - Quick Check",
creator="Invario",
)
doc.build(story)
output.seek(0)
return output.getvalue()
def generate_official_report(self, verify_result, event_counts, audit_rows):
"""
Generate a full official audit report (DIN 5008 compliant for authorities).
Returns:
bytes: PDF content
"""
output = io.BytesIO()
story = []
# Header
self._add_header(story, self.school_info.get('it_admin', 'IT-Beauftragter'))
# Title
reporting_date = self.created_timestamp.strftime('%d.%m.%Y')
self._add_title(story,
"Audit-Protokoll",
f"Revisonssicheres Audit-Log - Berichtsstand: {reporting_date}")
# Summary
self._add_audit_summary(story, verify_result, event_counts)
# Mismatches section (prominently)
mismatches = verify_result.get('mismatches', []) or []
if mismatches:
self._add_mismatches(story, mismatches)
# Full events table
self._add_events_table(story, audit_rows, include_payload=True)
# Page break for signature section
story.append(PageBreak())
# Signature block
self._add_signature_block(story)
# Footer
self._add_footer_info(story)
# Build PDF
doc = SimpleDocTemplate(
output,
pagesize=A4,
topMargin=self.MARGIN_TOP * cm,
bottomMargin=self.MARGIN_BOTTOM * cm,
leftMargin=self.MARGIN_LEFT * cm,
rightMargin=self.MARGIN_RIGHT * cm,
title="Audit Official Report",
author="Invario System",
subject="Offizielle Audit-Bericht (DIN 5008)",
creator="Invario",
)
doc.build(story)
output.seek(0)
return output.getvalue()
def generate_audit_pdf(verify_result, event_counts, audit_rows, export_type='official', school_info=None):
"""
Convenience function to generate audit PDFs.
Args:
verify_result (dict): Verification result from audit chain
event_counts (list): Event count statistics
audit_rows (list): Audit log entries
export_type (str): 'official' or 'quick'
school_info (dict): School information
Returns:
bytes: PDF content
"""
pdf_gen = DIN5008AuditPDF(school_info=school_info, export_type=export_type)
if export_type == 'quick':
return pdf_gen.generate_quick_check(verify_result, event_counts, audit_rows)
else:
return pdf_gen.generate_official_report(verify_result, event_counts, audit_rows)
+37 -3
View File
@@ -5,9 +5,43 @@
<h1>Audit Dashboard</h1>
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
<div style="display:flex; gap:10px; flex-wrap:wrap; margin:10px 0 18px;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export', format='md') }}">Audit Review exportieren (Markdown)</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}">Audit Review exportieren (JSON)</a>
<!-- Information Box for DIN 5008 Compliance -->
<div style="background:#e8f4f8; border-left:4px solid #0284c7; padding:12px; margin:0 0 16px 0; border-radius:4px;">
<p style="margin:0; font-size:0.95rem; color:#1e40af;">
<strong>✓ Professionelle PDF-Exporte:</strong> Die neuen DIN 5008 konformen Berichte sind speziell für Schulträger,
Rechnungsprüfungsämter und Behörden optimiert. Sie enthalten Revisionssicherheit, Barrierefreiheit (BFSG) und
PDF/A-Format für Langzeitarchivierung in deutschen Behörden.
</p>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:10px 0 18px;">
<!-- PDF Export Section -->
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Professionelle Berichte für Schulträger und Behörden</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_quick') }}" style="flex:1; text-align:center;">
🚀 Schnell-Check (kompakt)
</a>
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;">
📋 Amtlicher Bericht (DIN 5008)
</a>
</div>
</div>
<!-- Other Formats Section -->
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📊 Weitere Formate</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Technische Exporte für System-Integration</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='md') }}" style="flex:1; text-align:center;">
📝 Markdown
</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}" style="flex:1; text-align:center;">
⚙️ JSON
</a>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">