Files
Benjamin Admin 95fcba34cd
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 30s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
fix(quality): Ruff/CVE/TS-Fixes, 104 neue Tests, Complexity-Refactoring
- Ruff: 144 auto-fixes (unused imports, == None → is None), F821/F811/F841 manuell
- CVEs: python-multipart>=0.0.22, weasyprint>=68.0, pillow>=12.1.1, npm audit fix (0 vulns)
- TS: 5 tote Drafting-Engine-Dateien entfernt, allowed-facts/sanitizer/StepHeader/context fixes
- Tests: +104 (ISMS 58, Evidence 18, VVT 14, Generation 14) → 1449 passed
- Refactoring: collect_ci_evidence (F→A), row_to_response (E→A), extract_requirements (E→A)
- Dead Code: pca-platform, 7 Go-Handler, dsr_api.py, duplicate Schemas entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:00:33 +01:00

877 lines
31 KiB
Python

"""
Audit Session PDF Report Generator.
Sprint 3 Phase 4: Generates PDF reports for completed audit sessions.
Features:
- Cover page with audit session metadata
- Executive summary with traffic light status
- Statistics pie chart (compliant/non-compliant/pending)
- Detailed checklist with sign-off status
- Digital signature verification
- Appendix with non-compliant items
Uses reportlab for PDF generation (lightweight, no external dependencies).
"""
import io
import logging
from datetime import datetime
from typing import Dict, List, Any, Optional, Tuple
from sqlalchemy.orm import Session
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable
)
from reportlab.graphics.shapes import Drawing
from reportlab.graphics.charts.piecharts import Pie
from ..db.models import (
AuditSessionDB, AuditSignOffDB, AuditResultEnum, RequirementDB, RegulationDB
)
logger = logging.getLogger(__name__)
# =============================================================================
# Color Definitions
# =============================================================================
COLORS = {
'primary': colors.HexColor('#1a365d'), # Dark blue
'secondary': colors.HexColor('#2c5282'), # Medium blue
'accent': colors.HexColor('#3182ce'), # Light blue
'success': colors.HexColor('#38a169'), # Green
'warning': colors.HexColor('#d69e2e'), # Yellow/Orange
'danger': colors.HexColor('#e53e3e'), # Red
'muted': colors.HexColor('#718096'), # Gray
'light': colors.HexColor('#f7fafc'), # Light gray
'white': colors.white,
'black': colors.black,
}
RESULT_COLORS = {
'compliant': COLORS['success'],
'compliant_notes': colors.HexColor('#68d391'), # Light green
'non_compliant': COLORS['danger'],
'not_applicable': COLORS['muted'],
'pending': COLORS['warning'],
}
# =============================================================================
# Custom Styles
# =============================================================================
def get_custom_styles() -> Dict[str, ParagraphStyle]:
"""Create custom paragraph styles for the audit report."""
styles = getSampleStyleSheet()
custom = {
'Title': ParagraphStyle(
'AuditTitle',
parent=styles['Title'],
fontSize=24,
textColor=COLORS['primary'],
spaceAfter=12*mm,
alignment=TA_CENTER,
),
'Subtitle': ParagraphStyle(
'AuditSubtitle',
parent=styles['Normal'],
fontSize=14,
textColor=COLORS['secondary'],
spaceAfter=6*mm,
alignment=TA_CENTER,
),
'Heading1': ParagraphStyle(
'AuditH1',
parent=styles['Heading1'],
fontSize=18,
textColor=COLORS['primary'],
spaceBefore=12*mm,
spaceAfter=6*mm,
borderPadding=3*mm,
),
'Heading2': ParagraphStyle(
'AuditH2',
parent=styles['Heading2'],
fontSize=14,
textColor=COLORS['secondary'],
spaceBefore=8*mm,
spaceAfter=4*mm,
),
'Heading3': ParagraphStyle(
'AuditH3',
parent=styles['Heading3'],
fontSize=12,
textColor=COLORS['accent'],
spaceBefore=6*mm,
spaceAfter=3*mm,
),
'Normal': ParagraphStyle(
'AuditNormal',
parent=styles['Normal'],
fontSize=10,
textColor=COLORS['black'],
spaceAfter=3*mm,
alignment=TA_JUSTIFY,
),
'Small': ParagraphStyle(
'AuditSmall',
parent=styles['Normal'],
fontSize=8,
textColor=COLORS['muted'],
spaceAfter=2*mm,
),
'Footer': ParagraphStyle(
'AuditFooter',
parent=styles['Normal'],
fontSize=8,
textColor=COLORS['muted'],
alignment=TA_CENTER,
),
'Success': ParagraphStyle(
'AuditSuccess',
parent=styles['Normal'],
fontSize=10,
textColor=COLORS['success'],
),
'Warning': ParagraphStyle(
'AuditWarning',
parent=styles['Normal'],
fontSize=10,
textColor=COLORS['warning'],
),
'Danger': ParagraphStyle(
'AuditDanger',
parent=styles['Normal'],
fontSize=10,
textColor=COLORS['danger'],
),
}
return custom
# =============================================================================
# PDF Generator Class
# =============================================================================
class AuditPDFGenerator:
"""Generates PDF reports for audit sessions."""
def __init__(self, db: Session):
self.db = db
self.styles = get_custom_styles()
self.page_width, self.page_height = A4
self.margin = 20 * mm
def generate(
self,
session_id: str,
language: str = 'de',
include_signatures: bool = True,
) -> Tuple[bytes, str]:
"""
Generate a PDF report for an audit session.
Args:
session_id: The audit session ID
language: Report language ('de' or 'en')
include_signatures: Whether to include digital signature info
Returns:
Tuple of (PDF bytes, filename)
"""
# Load session with all related data
session = self._load_session(session_id)
if not session:
raise ValueError(f"Audit session {session_id} not found")
# Load all sign-offs
signoffs = self._load_signoffs(session_id)
signoff_map = {s.requirement_id: s for s in signoffs}
# Load requirements for this session
requirements = self._load_requirements(session)
# Calculate statistics
stats = self._calculate_statistics(session, signoffs)
# Generate PDF
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=self.margin,
rightMargin=self.margin,
topMargin=self.margin,
bottomMargin=self.margin,
)
# Build story (content)
story = []
# 1. Cover page
story.extend(self._build_cover_page(session, language))
story.append(PageBreak())
# 2. Executive summary
story.extend(self._build_executive_summary(session, stats, language))
story.append(PageBreak())
# 3. Statistics overview
story.extend(self._build_statistics_section(stats, language))
# 4. Detailed checklist
story.extend(self._build_checklist_section(
session, requirements, signoff_map, language
))
# 5. Non-compliant items appendix (if any)
non_compliant = [s for s in signoffs if s.result == AuditResultEnum.NON_COMPLIANT]
if non_compliant:
story.append(PageBreak())
story.extend(self._build_non_compliant_appendix(
non_compliant, requirements, language
))
# 6. Signature verification (if requested)
if include_signatures:
signed_items = [s for s in signoffs if s.signature_hash]
if signed_items:
story.append(PageBreak())
story.extend(self._build_signature_section(signed_items, language))
# Build the PDF
doc.build(story)
# Generate filename
date_str = datetime.utcnow().strftime('%Y%m%d')
filename = f"audit_report_{session.name.replace(' ', '_')}_{date_str}.pdf"
return buffer.getvalue(), filename
def _load_session(self, session_id: str) -> Optional[AuditSessionDB]:
"""Load an audit session by ID."""
return self.db.query(AuditSessionDB).filter(
AuditSessionDB.id == session_id
).first()
def _load_signoffs(self, session_id: str) -> List[AuditSignOffDB]:
"""Load all sign-offs for a session."""
return (
self.db.query(AuditSignOffDB)
.filter(AuditSignOffDB.session_id == session_id)
.all()
)
def _load_requirements(self, session: AuditSessionDB) -> List[RequirementDB]:
"""Load requirements for a session based on filters."""
query = self.db.query(RequirementDB).join(RegulationDB)
if session.regulation_ids:
query = query.filter(RegulationDB.code.in_(session.regulation_ids))
return query.order_by(RegulationDB.code, RequirementDB.article).all()
def _calculate_statistics(
self,
session: AuditSessionDB,
signoffs: List[AuditSignOffDB],
) -> Dict[str, Any]:
"""Calculate audit statistics."""
total = session.total_items
completed = len(signoffs)
compliant = sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT)
compliant_notes = sum(1 for s in signoffs if s.result == AuditResultEnum.COMPLIANT_WITH_NOTES)
non_compliant = sum(1 for s in signoffs if s.result == AuditResultEnum.NON_COMPLIANT)
not_applicable = sum(1 for s in signoffs if s.result == AuditResultEnum.NOT_APPLICABLE)
pending = total - completed
# Calculate compliance rate (excluding N/A and pending)
applicable = compliant + compliant_notes + non_compliant
compliance_rate = ((compliant + compliant_notes) / applicable * 100) if applicable > 0 else 0
return {
'total': total,
'completed': completed,
'pending': pending,
'compliant': compliant,
'compliant_notes': compliant_notes,
'non_compliant': non_compliant,
'not_applicable': not_applicable,
'completion_percentage': round((completed / total * 100) if total > 0 else 0, 1),
'compliance_rate': round(compliance_rate, 1),
'traffic_light': self._determine_traffic_light(compliance_rate, pending, total),
}
def _determine_traffic_light(
self,
compliance_rate: float,
pending: int,
total: int,
) -> str:
"""Determine traffic light status."""
pending_ratio = pending / total if total > 0 else 0
if pending_ratio > 0.3:
return 'yellow' # Too many pending items
elif compliance_rate >= 90:
return 'green'
elif compliance_rate >= 70:
return 'yellow'
else:
return 'red'
# =========================================================================
# Build Page Sections
# =========================================================================
def _build_cover_page(
self,
session: AuditSessionDB,
language: str,
) -> List:
"""Build the cover page."""
story = []
# Title
title = 'AUDIT-BERICHT' if language == 'de' else 'AUDIT REPORT'
story.append(Spacer(1, 30*mm))
story.append(Paragraph(title, self.styles['Title']))
# Session name
story.append(Paragraph(session.name, self.styles['Subtitle']))
story.append(Spacer(1, 15*mm))
# Horizontal rule
story.append(HRFlowable(
width="80%",
thickness=1,
color=COLORS['accent'],
spaceAfter=15*mm,
))
# Metadata table
labels = {
'de': {
'auditor': 'Auditor',
'organization': 'Organisation',
'status': 'Status',
'created': 'Erstellt am',
'started': 'Gestartet am',
'completed': 'Abgeschlossen am',
'regulations': 'Verordnungen',
},
'en': {
'auditor': 'Auditor',
'organization': 'Organization',
'status': 'Status',
'created': 'Created',
'started': 'Started',
'completed': 'Completed',
'regulations': 'Regulations',
},
}
l = labels.get(language, labels['de'])
status_map = {
'draft': 'Entwurf' if language == 'de' else 'Draft',
'in_progress': 'In Bearbeitung' if language == 'de' else 'In Progress',
'completed': 'Abgeschlossen' if language == 'de' else 'Completed',
'archived': 'Archiviert' if language == 'de' else 'Archived',
}
data = [
[l['auditor'], session.auditor_name],
[l['organization'], session.auditor_organization or '-'],
[l['status'], status_map.get(session.status.value, session.status.value)],
[l['created'], session.created_at.strftime('%d.%m.%Y %H:%M') if session.created_at else '-'],
[l['started'], session.started_at.strftime('%d.%m.%Y %H:%M') if session.started_at else '-'],
[l['completed'], session.completed_at.strftime('%d.%m.%Y %H:%M') if session.completed_at else '-'],
[l['regulations'], ', '.join(session.regulation_ids) if session.regulation_ids else 'Alle'],
]
table = Table(data, colWidths=[50*mm, 100*mm])
table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 11),
('TEXTCOLOR', (0, 0), (0, -1), COLORS['secondary']),
('TEXTCOLOR', (1, 0), (1, -1), COLORS['black']),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 8),
('ALIGN', (0, 0), (0, -1), 'RIGHT'),
('ALIGN', (1, 0), (1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
story.append(table)
story.append(Spacer(1, 20*mm))
# Description if available
if session.description:
desc_label = 'Beschreibung' if language == 'de' else 'Description'
story.append(Paragraph(f"<b>{desc_label}:</b>", self.styles['Normal']))
story.append(Paragraph(session.description, self.styles['Normal']))
# Generation timestamp
story.append(Spacer(1, 30*mm))
gen_label = 'Generiert am' if language == 'de' else 'Generated on'
story.append(Paragraph(
f"{gen_label}: {datetime.utcnow().strftime('%d.%m.%Y %H:%M')} UTC",
self.styles['Footer']
))
return story
def _build_executive_summary(
self,
session: AuditSessionDB,
stats: Dict[str, Any],
language: str,
) -> List:
"""Build the executive summary section."""
story = []
title = 'ZUSAMMENFASSUNG' if language == 'de' else 'EXECUTIVE SUMMARY'
story.append(Paragraph(title, self.styles['Heading1']))
# Traffic light status
traffic_light = stats['traffic_light']
tl_colors = {
'green': COLORS['success'],
'yellow': COLORS['warning'],
'red': COLORS['danger'],
}
tl_labels = {
'de': {'green': 'GUT', 'yellow': 'AUFMERKSAMKEIT', 'red': 'KRITISCH'},
'en': {'green': 'GOOD', 'yellow': 'ATTENTION', 'red': 'CRITICAL'},
}
# Create traffic light indicator
tl_table = Table(
[[tl_labels[language][traffic_light]]],
colWidths=[60*mm],
rowHeights=[15*mm],
)
tl_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, 0), tl_colors[traffic_light]),
('TEXTCOLOR', (0, 0), (0, 0), COLORS['white']),
('FONTNAME', (0, 0), (0, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (0, 0), 16),
('ALIGN', (0, 0), (0, 0), 'CENTER'),
('VALIGN', (0, 0), (0, 0), 'MIDDLE'),
('ROUNDEDCORNERS', [3, 3, 3, 3]),
]))
story.append(tl_table)
story.append(Spacer(1, 10*mm))
# Key metrics
labels = {
'de': {
'completion': 'Abschlussrate',
'compliance': 'Konformitaetsrate',
'total': 'Gesamtanforderungen',
'non_compliant': 'Nicht konform',
'pending': 'Ausstehend',
},
'en': {
'completion': 'Completion Rate',
'compliance': 'Compliance Rate',
'total': 'Total Requirements',
'non_compliant': 'Non-Compliant',
'pending': 'Pending',
},
}
l = labels.get(language, labels['de'])
metrics_data = [
[l['completion'], f"{stats['completion_percentage']}%"],
[l['compliance'], f"{stats['compliance_rate']}%"],
[l['total'], str(stats['total'])],
[l['non_compliant'], str(stats['non_compliant'])],
[l['pending'], str(stats['pending'])],
]
metrics_table = Table(metrics_data, colWidths=[60*mm, 40*mm])
metrics_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 12),
('TEXTCOLOR', (0, 0), (0, -1), COLORS['secondary']),
('TEXTCOLOR', (1, 0), (1, -1), COLORS['black']),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
('ALIGN', (0, 0), (0, -1), 'LEFT'),
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
('LINEABOVE', (0, 0), (-1, 0), 1, COLORS['light']),
('LINEBELOW', (0, -1), (-1, -1), 1, COLORS['light']),
]))
story.append(metrics_table)
story.append(Spacer(1, 10*mm))
# Key findings
findings_title = 'Wichtige Erkenntnisse' if language == 'de' else 'Key Findings'
story.append(Paragraph(f"<b>{findings_title}:</b>", self.styles['Heading3']))
findings = self._generate_findings(stats, language)
for finding in findings:
story.append(Paragraph(f"{finding}", self.styles['Normal']))
return story
def _generate_findings(self, stats: Dict[str, Any], language: str) -> List[str]:
"""Generate key findings based on statistics."""
findings = []
if language == 'de':
if stats['non_compliant'] > 0:
findings.append(
f"{stats['non_compliant']} Anforderungen sind nicht konform und "
f"erfordern Massnahmen."
)
if stats['pending'] > 0:
findings.append(
f"{stats['pending']} Anforderungen wurden noch nicht geprueft."
)
if stats['compliance_rate'] >= 90:
findings.append(
"Hohe Konformitaetsrate erreicht. Weiter so!"
)
elif stats['compliance_rate'] < 70:
findings.append(
"Konformitaetsrate unter 70%. Priorisierte Massnahmen erforderlich."
)
if stats['compliant_notes'] > 0:
findings.append(
f"{stats['compliant_notes']} Anforderungen sind konform mit Anmerkungen. "
f"Verbesserungspotenzial identifiziert."
)
if not findings:
findings.append("Audit vollstaendig abgeschlossen ohne kritische Befunde.")
else:
if stats['non_compliant'] > 0:
findings.append(
f"{stats['non_compliant']} requirements are non-compliant and "
f"require action."
)
if stats['pending'] > 0:
findings.append(
f"{stats['pending']} requirements have not been reviewed yet."
)
if stats['compliance_rate'] >= 90:
findings.append(
"High compliance rate achieved. Keep up the good work!"
)
elif stats['compliance_rate'] < 70:
findings.append(
"Compliance rate below 70%. Prioritized actions required."
)
if stats['compliant_notes'] > 0:
findings.append(
f"{stats['compliant_notes']} requirements are compliant with notes. "
f"Improvement potential identified."
)
if not findings:
findings.append("Audit completed without critical findings.")
return findings
def _build_statistics_section(
self,
stats: Dict[str, Any],
language: str,
) -> List:
"""Build the statistics overview section with pie chart."""
story = []
title = 'STATISTIK-UEBERSICHT' if language == 'de' else 'STATISTICS OVERVIEW'
story.append(Paragraph(title, self.styles['Heading1']))
# Create pie chart
drawing = Drawing(200, 200)
pie = Pie()
pie.x = 50
pie.y = 25
pie.width = 100
pie.height = 100
# Data for pie chart
data = [
stats['compliant'],
stats['compliant_notes'],
stats['non_compliant'],
stats['not_applicable'],
stats['pending'],
]
# Only include non-zero values
labels_de = ['Konform', 'Konform (Anm.)', 'Nicht konform', 'N/A', 'Ausstehend']
labels_en = ['Compliant', 'Compliant (Notes)', 'Non-Compliant', 'N/A', 'Pending']
labels = labels_de if language == 'de' else labels_en
pie_colors = [
COLORS['success'],
colors.HexColor('#68d391'),
COLORS['danger'],
COLORS['muted'],
COLORS['warning'],
]
# Filter out zero values
filtered_data = []
filtered_labels = []
filtered_colors = []
for i, val in enumerate(data):
if val > 0:
filtered_data.append(val)
filtered_labels.append(labels[i])
filtered_colors.append(pie_colors[i])
if filtered_data:
pie.data = filtered_data
pie.labels = filtered_labels
pie.slices.strokeWidth = 0.5
for i, col in enumerate(filtered_colors):
pie.slices[i].fillColor = col
drawing.add(pie)
story.append(drawing)
else:
no_data = 'Keine Daten verfuegbar' if language == 'de' else 'No data available'
story.append(Paragraph(no_data, self.styles['Normal']))
story.append(Spacer(1, 10*mm))
# Legend table
legend_data = []
for i, label in enumerate(labels):
if data[i] > 0:
count = data[i]
pct = round(count / stats['total'] * 100, 1) if stats['total'] > 0 else 0
legend_data.append([label, str(count), f"{pct}%"])
if legend_data:
header = ['Status', 'Anzahl', '%'] if language == 'de' else ['Status', 'Count', '%']
legend_table = Table([header] + legend_data, colWidths=[50*mm, 25*mm, 25*mm])
legend_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BACKGROUND', (0, 0), (-1, 0), COLORS['light']),
('ALIGN', (1, 0), (-1, -1), 'RIGHT'),
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
('TOPPADDING', (0, 0), (-1, -1), 5),
('GRID', (0, 0), (-1, -1), 0.5, COLORS['muted']),
]))
story.append(legend_table)
return story
def _build_checklist_section(
self,
session: AuditSessionDB,
requirements: List[RequirementDB],
signoff_map: Dict[str, AuditSignOffDB],
language: str,
) -> List:
"""Build the detailed checklist section."""
story = []
story.append(PageBreak())
title = 'PRUEFUNGSCHECKLISTE' if language == 'de' else 'AUDIT CHECKLIST'
story.append(Paragraph(title, self.styles['Heading1']))
# Group by regulation
by_regulation = {}
for req in requirements:
reg_code = req.regulation.code if req.regulation else 'OTHER'
if reg_code not in by_regulation:
by_regulation[reg_code] = []
by_regulation[reg_code].append(req)
result_labels = {
'de': {
'compliant': 'Konform',
'compliant_notes': 'Konform (Anm.)',
'non_compliant': 'Nicht konform',
'not_applicable': 'N/A',
'pending': 'Ausstehend',
},
'en': {
'compliant': 'Compliant',
'compliant_notes': 'Compliant (Notes)',
'non_compliant': 'Non-Compliant',
'not_applicable': 'N/A',
'pending': 'Pending',
},
}
labels = result_labels.get(language, result_labels['de'])
for reg_code, reqs in sorted(by_regulation.items()):
story.append(Paragraph(reg_code, self.styles['Heading2']))
# Build table data
header = ['Art.', 'Titel', 'Ergebnis', 'Signiert'] if language == 'de' else \
['Art.', 'Title', 'Result', 'Signed']
table_data = [header]
for req in reqs:
signoff = signoff_map.get(req.id)
result = signoff.result.value if signoff else 'pending'
result_label = labels.get(result, result)
signed = 'Ja' if (signoff and signoff.signature_hash) else '-'
if language == 'en':
signed = 'Yes' if (signoff and signoff.signature_hash) else '-'
# Truncate title if too long
title_text = req.title[:50] + '...' if len(req.title) > 50 else req.title
table_data.append([
req.article or '-',
title_text,
result_label,
signed,
])
table = Table(table_data, colWidths=[20*mm, 80*mm, 35*mm, 20*mm])
# Style rows based on result
style_commands = [
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BACKGROUND', (0, 0), (-1, 0), COLORS['light']),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('ALIGN', (2, 0), (3, -1), 'CENTER'),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('TOPPADDING', (0, 0), (-1, -1), 4),
('GRID', (0, 0), (-1, -1), 0.5, COLORS['muted']),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
]
# Color code results
for i, req in enumerate(reqs, start=1):
signoff = signoff_map.get(req.id)
if signoff:
result = signoff.result.value
if result == 'compliant':
style_commands.append(('TEXTCOLOR', (2, i), (2, i), COLORS['success']))
elif result == 'compliant_notes':
style_commands.append(('TEXTCOLOR', (2, i), (2, i), colors.HexColor('#2f855a')))
elif result == 'non_compliant':
style_commands.append(('TEXTCOLOR', (2, i), (2, i), COLORS['danger']))
else:
style_commands.append(('TEXTCOLOR', (2, i), (2, i), COLORS['warning']))
table.setStyle(TableStyle(style_commands))
story.append(table)
story.append(Spacer(1, 5*mm))
return story
def _build_non_compliant_appendix(
self,
non_compliant: List[AuditSignOffDB],
requirements: List[RequirementDB],
language: str,
) -> List:
"""Build appendix with non-compliant items detail."""
story = []
title = 'ANHANG: NICHT KONFORME ANFORDERUNGEN' if language == 'de' else \
'APPENDIX: NON-COMPLIANT REQUIREMENTS'
story.append(Paragraph(title, self.styles['Heading1']))
req_map = {r.id: r for r in requirements}
for i, signoff in enumerate(non_compliant, start=1):
req = req_map.get(signoff.requirement_id)
if not req:
continue
# Requirement header
story.append(Paragraph(
f"<b>{i}. {req.regulation.code if req.regulation else ''} {req.article}</b>",
self.styles['Heading3']
))
story.append(Paragraph(f"<b>{req.title}</b>", self.styles['Normal']))
if req.description:
desc = req.description[:500] + '...' if len(req.description) > 500 else req.description
story.append(Paragraph(desc, self.styles['Small']))
# Notes from auditor
if signoff.notes:
notes_label = 'Auditor-Anmerkungen' if language == 'de' else 'Auditor Notes'
story.append(Paragraph(f"<b>{notes_label}:</b>", self.styles['Normal']))
story.append(Paragraph(signoff.notes, self.styles['Normal']))
story.append(Spacer(1, 5*mm))
return story
def _build_signature_section(
self,
signed_items: List[AuditSignOffDB],
language: str,
) -> List:
"""Build section with digital signature verification."""
story = []
title = 'DIGITALE SIGNATUREN' if language == 'de' else 'DIGITAL SIGNATURES'
story.append(Paragraph(title, self.styles['Heading1']))
explanation = (
'Die folgenden Pruefpunkte wurden digital signiert. '
'Die SHA-256 Hashes dienen als unveraenderlicher Nachweis des Pruefergebnisses.'
) if language == 'de' else (
'The following audit items have been digitally signed. '
'The SHA-256 hashes serve as immutable proof of the audit result.'
)
story.append(Paragraph(explanation, self.styles['Normal']))
story.append(Spacer(1, 5*mm))
header = ['Anforderung', 'Signiert von', 'Datum', 'SHA-256 (gekuerzt)'] if language == 'de' else \
['Requirement', 'Signed by', 'Date', 'SHA-256 (truncated)']
table_data = [header]
for item in signed_items[:50]: # Limit to 50 entries
table_data.append([
item.requirement_id[:8] + '...',
item.signed_by or '-',
item.signed_at.strftime('%d.%m.%Y') if item.signed_at else '-',
item.signature_hash[:16] + '...' if item.signature_hash else '-',
])
table = Table(table_data, colWidths=[35*mm, 40*mm, 30*mm, 50*mm])
table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTNAME', (0, 1), (-1, -1), 'Courier'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('BACKGROUND', (0, 0), (-1, 0), COLORS['light']),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
('TOPPADDING', (0, 0), (-1, -1), 3),
('GRID', (0, 0), (-1, -1), 0.5, COLORS['muted']),
]))
story.append(table)
return story