0c27d7ac86
- Implemented `excel_export.py` for generating library item exports in Excel format. - Created `pdf_export.py` for generating audit reports compliant with DIN 5008 standards, including detailed event tables and signature blocks. - Developed `generate_user.py` for interactive user creation with validation for usernames and passwords. - Introduced `module_registry.py` for managing module states and path matching. - Added a basic `__init__.py` in the `terminplaner` module for initialization.
790 lines
29 KiB
Python
790 lines
29 KiB
Python
"""
|
|
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 os
|
|
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
|
|
import Web.modules.database.settings as cfg
|
|
|
|
|
|
__version__ = cfg.APP_VERSION
|
|
|
|
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'),
|
|
)
|
|
|
|
logo_path = self.school_info.get('logo_path', '')
|
|
resolved_logo_path = None
|
|
if logo_path:
|
|
candidate_paths = [
|
|
logo_path,
|
|
os.path.join(cfg.UPLOAD_FOLDER, logo_path),
|
|
os.path.join('/opt/Inventarsystem/Web/uploads', logo_path),
|
|
os.path.join('/var/Inventarsystem/Web/uploads', logo_path),
|
|
]
|
|
for candidate_path in candidate_paths:
|
|
if candidate_path and os.path.exists(candidate_path):
|
|
resolved_logo_path = candidate_path
|
|
break
|
|
|
|
school_info_text = f"""
|
|
<b>{school_name}</b><br/>
|
|
{address}<br/>
|
|
{postal_code} {city}<br/>
|
|
<i>Schulnummer: {school_number}</i>
|
|
"""
|
|
|
|
if resolved_logo_path:
|
|
logo_image = Image(resolved_logo_path)
|
|
try:
|
|
logo_image._restrictSize(3.4 * cm, 3.4 * cm)
|
|
except Exception:
|
|
pass
|
|
|
|
school_table = Table(
|
|
[[logo_image, Paragraph(school_info_text, header_style)]],
|
|
colWidths=[3.8 * cm, self.USABLE_WIDTH - 3.8 * cm],
|
|
)
|
|
school_table.setStyle(TableStyle([
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
('LEFTPADDING', (0, 0), (-1, -1), 0),
|
|
('RIGHTPADDING', (0, 0), (-1, -1), 0),
|
|
('TOPPADDING', (0, 0), (-1, -1), 0),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
|
|
]))
|
|
story.append(school_table)
|
|
else:
|
|
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 v{__version__}
|
|
"""
|
|
|
|
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 with wrapped cells for better readability on A4 pages
|
|
cell_style = ParagraphStyle(
|
|
'EventCell',
|
|
parent=styles['Normal'],
|
|
fontName='Helvetica',
|
|
fontSize=8,
|
|
leading=9.5,
|
|
textColor=HexColor('#1f2937'),
|
|
alignment=TA_LEFT,
|
|
)
|
|
hash_style = ParagraphStyle(
|
|
'EventHashCell',
|
|
parent=cell_style,
|
|
fontName='Courier',
|
|
fontSize=7.2,
|
|
leading=9,
|
|
wordWrap='CJK',
|
|
)
|
|
|
|
def _safe(value):
|
|
return str(value or '').replace('&', '&').replace('<', '<').replace('>', '>')
|
|
|
|
def _fmt_ts(value):
|
|
text = _safe(value)
|
|
if len(text) >= 19 and text[4] == '-' and text[7] == '-':
|
|
# Convert 2026-05-10 16:19:07... -> 10.05.2026 16:19
|
|
return f"{text[8:10]}.{text[5:7]}.{text[0:4]} {text[11:16]}"
|
|
return text[:16]
|
|
|
|
def _fmt_event(value):
|
|
text = _safe(value).replace('_', ' ').strip()
|
|
return text[:60]
|
|
|
|
def _chunk_text(value, chunk=4, sep=' '):
|
|
text = _safe(value)
|
|
if not text:
|
|
return ''
|
|
return sep.join(text[i:i + chunk] for i in range(0, len(text), chunk))
|
|
|
|
def _fmt_ip(value):
|
|
text = _safe(value)
|
|
# Keep IPv4 intact. For long IPv6 values insert only one line break in the middle.
|
|
if ':' in text and len(text) > 24:
|
|
parts = text.split(':')
|
|
if len(parts) > 4:
|
|
return ':'.join(parts[:4]) + ':<br/>' + ':'.join(parts[4:])
|
|
return text
|
|
|
|
# Build table data
|
|
if self.export_type == 'quick':
|
|
# Quick-Check: Minimal columns
|
|
table_data = [
|
|
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Hash (gekürzt)'],
|
|
]
|
|
|
|
for row in audit_rows[:20]: # Limit to 20 rows for quick check
|
|
chain_idx = _safe(row.get('chain_index', ''))
|
|
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
|
|
event_type = _fmt_event(row.get('event_type', ''))
|
|
actor = _safe(row.get('actor', ''))
|
|
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:20], chunk=4)
|
|
if entry_hash:
|
|
entry_hash += ' ...'
|
|
|
|
table_data.append([
|
|
Paragraph(chain_idx, cell_style),
|
|
Paragraph(timestamp, cell_style),
|
|
Paragraph(event_type, cell_style),
|
|
Paragraph(actor, cell_style),
|
|
Paragraph(entry_hash, hash_style),
|
|
])
|
|
|
|
colWidths = [1.1*cm, 2.7*cm, 4.8*cm, 3.1*cm, 4.9*cm]
|
|
else:
|
|
# Official Report: Full columns
|
|
table_data = [
|
|
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Quelle', 'IP', 'Hash'],
|
|
]
|
|
|
|
for row in audit_rows:
|
|
chain_idx = _safe(row.get('chain_index', ''))
|
|
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
|
|
event_type = _fmt_event(row.get('event_type', ''))
|
|
actor = _safe(row.get('actor', ''))
|
|
source = _safe(row.get('source', 'System'))
|
|
ip = _fmt_ip(row.get('ip', ''))
|
|
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:40], chunk=8)
|
|
|
|
table_data.append([
|
|
Paragraph(chain_idx, cell_style),
|
|
Paragraph(timestamp, cell_style),
|
|
Paragraph(event_type, cell_style),
|
|
Paragraph(actor, cell_style),
|
|
Paragraph(source, cell_style),
|
|
Paragraph(ip, cell_style),
|
|
Paragraph(entry_hash, hash_style),
|
|
])
|
|
|
|
# Give IP and hash columns significantly more room for readability.
|
|
colWidths = [0.9*cm, 2.4*cm, 2.8*cm, 2.0*cm, 1.4*cm, 3.3*cm, 4.2*cm]
|
|
|
|
# Create table
|
|
events_table = Table(table_data, colWidths=colWidths, repeatRows=1)
|
|
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.5),
|
|
('TOPPADDING', (0, 0), (-1, 0), 6),
|
|
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
|
|
|
|
# Body styling
|
|
('FONTSIZE', (0, 1), (-1, -1), 8),
|
|
('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), 5),
|
|
('BOTTOMPADDING', (0, 1), (-1, -1), 5),
|
|
('LEFTPADDING', (0, 1), (-1, -1), 4),
|
|
('RIGHTPADDING', (0, 1), (-1, -1), 4),
|
|
('ALIGN', (0, 1), (0, -1), 'CENTER'),
|
|
('ALIGN', (1, 1), (1, -1), 'CENTER'),
|
|
('ALIGN', (-1, 1), (-1, -1), 'LEFT'),
|
|
]))
|
|
|
|
story.append(events_table)
|
|
story.append(Paragraph(
|
|
"Hinweis: Zeitangaben sind auf Minuten gerundet; Hashwerte werden aus Platzgründen gekürzt dargestellt.",
|
|
ParagraphStyle(
|
|
'TableHint',
|
|
parent=styles['Normal'],
|
|
fontSize=7.5,
|
|
leading=9,
|
|
textColor=HexColor('#6b7280'),
|
|
alignment=TA_LEFT,
|
|
spaceBefore=4,
|
|
)
|
|
))
|
|
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)
|
|
|
|
def _build_invoice_pdf(invoice_data):
|
|
"""Render a PDF invoice for a damaged borrowed item."""
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.units import mm
|
|
from reportlab.lib.colors import HexColor, black, white
|
|
from reportlab.lib.utils import simpleSplit
|
|
from reportlab.pdfgen import canvas
|
|
|
|
pdf_buffer = io.BytesIO()
|
|
c = canvas.Canvas(pdf_buffer, pagesize=A4)
|
|
page_width, page_height = A4
|
|
|
|
margin_x = 20 * mm
|
|
margin_top = 20 * mm
|
|
usable_width = page_width - (2 * margin_x)
|
|
current_y = page_height - margin_top
|
|
|
|
dark_color = HexColor('#0F172A')
|
|
accent_color = HexColor('#B91C1C')
|
|
light_color = HexColor('#F8FAFC')
|
|
border_color = HexColor('#CBD5E1')
|
|
text_color = HexColor('#1E293B')
|
|
muted_color = HexColor('#64748B')
|
|
|
|
def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color):
|
|
if not text:
|
|
return y_pos
|
|
c.setFont(font_name, font_size)
|
|
c.setFillColor(color)
|
|
for line in simpleSplit(str(text), font_name, font_size, width):
|
|
c.drawString(x_pos, y_pos, line)
|
|
y_pos -= leading
|
|
return y_pos
|
|
|
|
def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm):
|
|
c.setFont('Helvetica-Bold', 10)
|
|
c.setFillColor(muted_color)
|
|
c.drawString(x_pos, y_pos, label)
|
|
c.setFont('Helvetica', 10)
|
|
c.setFillColor(text_color)
|
|
c.drawString(x_pos + label_width, y_pos, str(value or '-'))
|
|
return y_pos - 7 * mm
|
|
|
|
c.setFillColor(light_color)
|
|
c.rect(0, 0, page_width, page_height, fill=1, stroke=0)
|
|
|
|
c.setFillColor(dark_color)
|
|
c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0)
|
|
c.setFillColor(white)
|
|
c.setFont('Helvetica-Bold', 20)
|
|
c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG')
|
|
c.setFont('Helvetica', 10)
|
|
c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt')
|
|
|
|
current_y = page_height - 40 * mm
|
|
c.setStrokeColor(border_color)
|
|
c.setLineWidth(1)
|
|
c.line(margin_x, current_y, page_width - margin_x, current_y)
|
|
current_y -= 12 * mm
|
|
|
|
invoice_number = invoice_data.get('invoice_number', '-')
|
|
created_at = invoice_data.get('created_at_display', '-')
|
|
borrower = invoice_data.get('borrower', '-')
|
|
item_name = invoice_data.get('item_name', '-')
|
|
item_code = invoice_data.get('item_code', '-')
|
|
item_id = invoice_data.get('item_id', '-')
|
|
damage_reason = invoice_data.get('damage_reason', '-')
|
|
amount_text = invoice_data.get('amount_text', '-')
|
|
|
|
current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y)
|
|
current_y = draw_label_value('Datum:', created_at, margin_x, current_y)
|
|
current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y)
|
|
current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y)
|
|
current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y)
|
|
current_y = draw_label_value('Code:', item_code, margin_x, current_y)
|
|
current_y = current_y - 3 * mm
|
|
|
|
c.setFillColor(dark_color)
|
|
c.setFont('Helvetica-Bold', 12)
|
|
c.drawString(margin_x, current_y, 'Schadensbeschreibung')
|
|
current_y -= 6 * mm
|
|
current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color)
|
|
current_y -= 4 * mm
|
|
|
|
c.setFillColor(dark_color)
|
|
c.setFont('Helvetica-Bold', 12)
|
|
c.drawString(margin_x, current_y, 'Rechnungsbetrag')
|
|
current_y -= 8 * mm
|
|
|
|
c.setFillColor(accent_color)
|
|
c.setStrokeColor(accent_color)
|
|
c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0)
|
|
c.setFillColor(white)
|
|
c.setFont('Helvetica-Bold', 16)
|
|
c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text)
|
|
current_y -= 20 * mm
|
|
|
|
current_y = draw_wrapped_lines(
|
|
'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.',
|
|
margin_x,
|
|
current_y,
|
|
usable_width,
|
|
font_size=10,
|
|
leading=13,
|
|
color=muted_color,
|
|
)
|
|
|
|
footer_y = 18 * mm
|
|
c.setStrokeColor(border_color)
|
|
c.setLineWidth(0.8)
|
|
c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm)
|
|
c.setFillColor(muted_color)
|
|
c.setFont('Helvetica', 9)
|
|
c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung')
|
|
c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}')
|
|
|
|
c.save()
|
|
pdf_buffer.seek(0)
|
|
return pdf_buffer |