""" 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, timezone 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.now(timezone.utc).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"{desc_label}:", 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.now(timezone.utc).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"{findings_title}:", 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"{i}. {req.regulation.code if req.regulation else ''} {req.article}", self.styles['Heading3'] )) story.append(Paragraph(f"{req.title}", 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"{notes_label}:", 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