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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 14:02 +0800
1from __future__ import annotations
3from datetime import UTC
4import io
5from typing import TYPE_CHECKING, Any
7from lexigram.admin.services.export.service import IExportBackend
9if TYPE_CHECKING:
10 from lexigram.admin.services.export.scheduler import ExportJob
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 )
27 HAS_REPORTLAB = True
28except ImportError:
29 HAS_REPORTLAB = False
32class PdfExportBackend(IExportBackend):
33 """Backend for PDF report exports."""
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")
46 from datetime import datetime
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}"
52 buffer = io.BytesIO()
53 doc = SimpleDocTemplate(buffer, pagesize=A4)
55 styles = getSampleStyleSheet()
56 story = []
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))
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 ]
75 for meta in metadata:
76 story.append(Paragraph(meta, styles["Normal"]))
77 story.append(Spacer(1, 20))
79 if data:
80 # Convert to table format
81 fieldnames = job.columns if job.columns else list(data[0].keys())
83 # Table header
84 table_data = [fieldnames]
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)
91 # Create table
92 table = Table(table_data, repeatRows=1)
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)
109 story.append(table)
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 )
121 # Build PDF
122 doc.build(story)
124 # Save to storage
125 await storage.upload(
126 file_path, buffer.getvalue(), content_type="application/pdf"
127 )
129 return file_path