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
- 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>
877 lines
31 KiB
Python
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
|