Coverage for src / lexigram / admin / services / export / adapters / pdf.py: 23%

48 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 14:02 +0800

1from __future__ import annotations 

2 

3from datetime import UTC 

4import io 

5from typing import TYPE_CHECKING, Any 

6 

7from lexigram.admin.services.export.service import IExportBackend 

8 

9if TYPE_CHECKING: 

10 from lexigram.admin.services.export.scheduler import ExportJob 

11 

12try: 

13 from reportlab.lib import colors # type: ignore[import-untyped] 

14 from reportlab.lib.pagesizes import A4 # type: ignore[import-untyped] 

15 from reportlab.lib.styles import ( # type: ignore[import-untyped] 

16 ParagraphStyle, 

17 getSampleStyleSheet, 

18 ) 

19 from reportlab.platypus import ( # type: ignore[import-untyped] 

20 Paragraph, 

21 SimpleDocTemplate, 

22 Spacer, 

23 Table, 

24 TableStyle, 

25 ) 

26 

27 HAS_REPORTLAB = True 

28except ImportError: 

29 HAS_REPORTLAB = False 

30 

31 

32class PdfExportBackend(IExportBackend): 

33 """Backend for PDF report exports.""" 

34 

35 async def generate_file( 

36 self, 

37 job: ExportJob, 

38 data: list[dict[str, Any]], 

39 storage: Any, 

40 export_dir: str, 

41 ) -> str: 

42 """Export data to PDF format.""" 

43 if not HAS_REPORTLAB: 

44 raise ImportError("reportlab is required for PDF export") 

45 

46 from datetime import datetime 

47 

48 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") 

49 filename = f"{job.resource_name}_export_{timestamp}.pdf" 

50 file_path = f"{export_dir}/{filename}" 

51 

52 buffer = io.BytesIO() 

53 doc = SimpleDocTemplate(buffer, pagesize=A4) 

54 

55 styles = getSampleStyleSheet() 

56 story = [] 

57 

58 # Title 

59 title_style = ParagraphStyle( 

60 "CustomTitle", 

61 parent=styles["Heading1"], 

62 fontSize=16, 

63 spaceAfter=30, 

64 ) 

65 story.append(Paragraph(f"{job.resource_name} Export Report", title_style)) 

66 story.append(Spacer(1, 12)) 

67 

68 # Metadata 

69 metadata = [ 

70 f"Generated: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S')} UTC", 

71 f"Total Records: {len(data)}", 

72 f"Filters Applied: {', '.join(job.filters.keys()) if job.filters else 'None'}", 

73 ] 

74 

75 for meta in metadata: 

76 story.append(Paragraph(meta, styles["Normal"])) 

77 story.append(Spacer(1, 20)) 

78 

79 if data: 

80 # Convert to table format 

81 fieldnames = job.columns if job.columns else list(data[0].keys()) 

82 

83 # Table header 

84 table_data = [fieldnames] 

85 

86 # Table rows (limit to first 1000 for PDF) 

87 for row in data[:1000]: 

88 table_row = [str(row.get(field, "")) for field in fieldnames] 

89 table_data.append(table_row) 

90 

91 # Create table 

92 table = Table(table_data, repeatRows=1) 

93 

94 # Table styling 

95 table_style = TableStyle( 

96 [ 

97 ("BACKGROUND", (0, 0), (-1, 0), colors.grey), 

98 ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), 

99 ("ALIGN", (0, 0), (-1, -1), "LEFT"), 

100 ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), 

101 ("FONTSIZE", (0, 0), (-1, 0), 10), 

102 ("BOTTOMPADDING", (0, 0), (-1, 0), 12), 

103 ("BACKGROUND", (0, 1), (-1, -1), colors.white), 

104 ("GRID", (0, 0), (-1, -1), 1, colors.black), 

105 ], 

106 ) 

107 table.setStyle(table_style) 

108 

109 story.append(table) 

110 

111 if len(data) > 1000: 

112 story.append(Spacer(1, 12)) 

113 story.append( 

114 Paragraph( 

115 f"Note: Showing first 1000 of {len(data)} records. " 

116 "Use CSV/Excel export for complete dataset.", 

117 styles["Italic"], 

118 ), 

119 ) 

120 

121 # Build PDF 

122 doc.build(story) 

123 

124 # Save to storage 

125 await storage.upload( 

126 file_path, buffer.getvalue(), content_type="application/pdf" 

127 ) 

128 

129 return file_path