anderson-ufrj commited on
Commit
8d6b4c3
·
1 Parent(s): a5971b4

feat(export): implement comprehensive document export system

Browse files

- Add export service with PDF, Excel, CSV and bulk export capabilities
- Integrate PDF generation into Tiradentes agent with _render_pdf method
- Create export API routes for investigations, contracts and anomalies
- Add support for bulk export with ZIP compression
- Include comprehensive test coverage for export functionality
- Update requirements.txt with reportlab, openpyxl, markdown, beautifulsoup4

The system now supports full document export in multiple formats:
- PDF generation with custom styling and metadata
- Excel export with multiple sheets and formatting
- CSV export for data analysis
- Bulk export with ZIP compression for multiple files

requirements.txt CHANGED
@@ -36,4 +36,11 @@ graphql-core>=3.2.0
36
 
37
  # Basic OpenTelemetry without complex dependencies
38
  opentelemetry-api==1.21.0
39
- opentelemetry-sdk==1.21.0
 
 
 
 
 
 
 
 
36
 
37
  # Basic OpenTelemetry without complex dependencies
38
  opentelemetry-api==1.21.0
39
+ opentelemetry-sdk==1.21.0
40
+
41
+ # Export libraries
42
+ reportlab>=4.0.9
43
+ openpyxl>=3.1.2
44
+ markdown>=3.5.1
45
+ beautifulsoup4>=4.12.2
46
+ weasyprint>=60.2
src/agents/tiradentes.py CHANGED
@@ -18,6 +18,7 @@ from pydantic import BaseModel, Field as PydanticField
18
  from src.agents.deodoro import BaseAgent, AgentContext, AgentMessage, AgentResponse
19
  from src.core import get_logger, AgentStatus
20
  from src.core.exceptions import AgentExecutionError
 
21
 
22
 
23
  class ReportFormat(str, Enum):
@@ -127,6 +128,7 @@ class ReporterAgent(BaseAgent):
127
  ReportFormat.MARKDOWN: self._render_markdown,
128
  ReportFormat.HTML: self._render_html,
129
  ReportFormat.JSON: self._render_json,
 
130
  ReportFormat.EXECUTIVE_SUMMARY: self._render_executive_summary,
131
  }
132
 
@@ -1034,4 +1036,32 @@ class ReporterAgent(BaseAgent):
1034
  content.append(f"Explicação: {anomaly.get('explanation', 'N/A')}")
1035
  content.append("")
1036
 
1037
- return "\n".join(content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  from src.agents.deodoro import BaseAgent, AgentContext, AgentMessage, AgentResponse
19
  from src.core import get_logger, AgentStatus
20
  from src.core.exceptions import AgentExecutionError
21
+ from src.services.export_service import export_service
22
 
23
 
24
  class ReportFormat(str, Enum):
 
128
  ReportFormat.MARKDOWN: self._render_markdown,
129
  ReportFormat.HTML: self._render_html,
130
  ReportFormat.JSON: self._render_json,
131
+ ReportFormat.PDF: self._render_pdf,
132
  ReportFormat.EXECUTIVE_SUMMARY: self._render_executive_summary,
133
  }
134
 
 
1036
  content.append(f"Explicação: {anomaly.get('explanation', 'N/A')}")
1037
  content.append("")
1038
 
1039
+ return "\n".join(content)
1040
+
1041
+ async def _render_pdf(
1042
+ self,
1043
+ sections: List[ReportSection],
1044
+ request: ReportRequest,
1045
+ context: AgentContext
1046
+ ) -> str:
1047
+ """Render report in PDF format and return base64 encoded string."""
1048
+ # First convert sections to markdown
1049
+ markdown_content = await self._render_markdown(sections, request, context)
1050
+
1051
+ # Generate PDF using export service
1052
+ pdf_bytes = await export_service.generate_pdf(
1053
+ content=markdown_content,
1054
+ title=f"Relatório: {request.report_type.value.replace('_', ' ').title()}",
1055
+ metadata={
1056
+ 'generated_at': datetime.utcnow().isoformat(),
1057
+ 'report_type': request.report_type.value,
1058
+ 'investigation_id': context.investigation_id,
1059
+ 'target_audience': request.target_audience,
1060
+ 'author': 'Agente Tiradentes - Cidadão.AI'
1061
+ },
1062
+ format_type="report"
1063
+ )
1064
+
1065
+ # Return base64 encoded PDF for easy transmission
1066
+ import base64
1067
+ return base64.b64encode(pdf_bytes).decode('utf-8')
src/api/app.py CHANGED
@@ -287,6 +287,13 @@ app.include_router(
287
  tags=["Reports"]
288
  )
289
 
 
 
 
 
 
 
 
290
  app.include_router(
291
  chat.router,
292
  prefix="/api/v1/chat",
 
287
  tags=["Reports"]
288
  )
289
 
290
+ from src.api.routes import export
291
+ app.include_router(
292
+ export.router,
293
+ prefix="/api/v1/export",
294
+ tags=["Export"]
295
+ )
296
+
297
  app.include_router(
298
  chat.router,
299
  prefix="/api/v1/chat",
src/api/routes/export.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: api.routes.export
3
+ Description: Export endpoints for downloading investigations, reports and data
4
+ Author: Anderson H. Silva
5
+ Date: 2025-01-25
6
+ License: Proprietary - All rights reserved
7
+ """
8
+
9
+ import io
10
+ from datetime import datetime
11
+ from typing import Dict, List, Optional, Any, Union
12
+ from uuid import uuid4
13
+
14
+ from fastapi import APIRouter, HTTPException, Depends, Query, Response
15
+ from fastapi.responses import StreamingResponse
16
+ import pandas as pd
17
+ from pydantic import BaseModel, Field as PydanticField, validator
18
+
19
+ from src.core import json_utils
20
+ from src.core import get_logger
21
+ from src.api.middleware.authentication import get_current_user
22
+ from src.services.export_service import export_service
23
+ from src.services.investigation_service import investigation_service
24
+ from src.services.data_service import data_service
25
+
26
+ logger = get_logger(__name__)
27
+
28
+ router = APIRouter()
29
+
30
+
31
+ class ExportRequest(BaseModel):
32
+ """Request model for data export."""
33
+
34
+ export_type: str = PydanticField(description="Type of data to export")
35
+ format: str = PydanticField(description="Export format")
36
+ filters: Optional[Dict[str, Any]] = PydanticField(default={}, description="Filters to apply")
37
+ include_metadata: bool = PydanticField(default=True, description="Include metadata")
38
+ compress: bool = PydanticField(default=False, description="Compress output")
39
+
40
+ @validator('export_type')
41
+ def validate_export_type(cls, v):
42
+ """Validate export type."""
43
+ allowed_types = [
44
+ 'investigations', 'contracts', 'anomalies',
45
+ 'reports', 'analytics', 'full_data'
46
+ ]
47
+ if v not in allowed_types:
48
+ raise ValueError(f'Export type must be one of: {allowed_types}')
49
+ return v
50
+
51
+ @validator('format')
52
+ def validate_format(cls, v):
53
+ """Validate export format."""
54
+ allowed_formats = ['excel', 'csv', 'json', 'pdf']
55
+ if v not in allowed_formats:
56
+ raise ValueError(f'Format must be one of: {allowed_formats}')
57
+ return v
58
+
59
+
60
+ class BulkExportRequest(BaseModel):
61
+ """Request model for bulk export."""
62
+
63
+ exports: List[Dict[str, Any]] = PydanticField(description="List of exports to generate")
64
+ compress: bool = PydanticField(default=True, description="Compress all exports")
65
+
66
+ @validator('exports')
67
+ def validate_exports(cls, v):
68
+ """Validate exports list."""
69
+ if not v:
70
+ raise ValueError('At least one export must be specified')
71
+ if len(v) > 50:
72
+ raise ValueError('Maximum 50 exports allowed per request')
73
+ return v
74
+
75
+
76
+ @router.post("/investigations/{investigation_id}/download")
77
+ async def export_investigation(
78
+ investigation_id: str,
79
+ format: str = Query("excel", description="Export format: excel, csv, pdf, json"),
80
+ current_user: Dict[str, Any] = Depends(get_current_user)
81
+ ):
82
+ """
83
+ Export investigation data in various formats.
84
+
85
+ Exports complete investigation data including anomalies,
86
+ contracts, and analysis results.
87
+ """
88
+ # Get investigation data
89
+ investigation = await investigation_service.get_investigation(
90
+ investigation_id,
91
+ user_id=current_user.get("user_id")
92
+ )
93
+
94
+ if not investigation:
95
+ raise HTTPException(status_code=404, detail="Investigation not found")
96
+
97
+ filename = f"investigation_{investigation_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
98
+
99
+ if format == "excel":
100
+ # Convert to Excel
101
+ file_bytes = await export_service.convert_investigation_to_excel(investigation)
102
+
103
+ return Response(
104
+ content=file_bytes,
105
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
106
+ headers={
107
+ "Content-Disposition": f"attachment; filename={filename}.xlsx"
108
+ }
109
+ )
110
+
111
+ elif format == "csv":
112
+ # Create CSV with main data
113
+ main_data = {
114
+ 'investigation_id': investigation['id'],
115
+ 'type': investigation['type'],
116
+ 'status': investigation['status'],
117
+ 'created_at': investigation['created_at'],
118
+ 'anomalies_count': len(investigation.get('anomalies', [])),
119
+ 'total_value': investigation.get('total_value', 0),
120
+ }
121
+
122
+ df = pd.DataFrame([main_data])
123
+ csv_bytes = await export_service.generate_csv(df)
124
+
125
+ return Response(
126
+ content=csv_bytes,
127
+ media_type="text/csv",
128
+ headers={
129
+ "Content-Disposition": f"attachment; filename={filename}.csv"
130
+ }
131
+ )
132
+
133
+ elif format == "pdf":
134
+ # Generate PDF report
135
+ content = _format_investigation_as_markdown(investigation)
136
+ pdf_bytes = await export_service.generate_pdf(
137
+ content=content,
138
+ title=f"Investigação {investigation_id}",
139
+ metadata={
140
+ 'investigation_id': investigation_id,
141
+ 'generated_at': datetime.now().isoformat(),
142
+ 'user': current_user.get('email', 'Unknown')
143
+ }
144
+ )
145
+
146
+ return Response(
147
+ content=pdf_bytes,
148
+ media_type="application/pdf",
149
+ headers={
150
+ "Content-Disposition": f"attachment; filename={filename}.pdf"
151
+ }
152
+ )
153
+
154
+ elif format == "json":
155
+ return Response(
156
+ content=json_utils.dumps(investigation, indent=2, ensure_ascii=False),
157
+ media_type="application/json",
158
+ headers={
159
+ "Content-Disposition": f"attachment; filename={filename}.json"
160
+ }
161
+ )
162
+
163
+ else:
164
+ raise HTTPException(status_code=400, detail="Unsupported format")
165
+
166
+
167
+ @router.post("/contracts/export")
168
+ async def export_contracts(
169
+ request: ExportRequest,
170
+ current_user: Dict[str, Any] = Depends(get_current_user)
171
+ ):
172
+ """
173
+ Export contract data with filters.
174
+
175
+ Allows exporting filtered contract data in various formats.
176
+ """
177
+ # Apply filters
178
+ filters = request.filters or {}
179
+
180
+ # Get contracts data
181
+ contracts = await data_service.search_contracts(
182
+ **filters,
183
+ limit=10000 # Reasonable limit for exports
184
+ )
185
+
186
+ if not contracts:
187
+ raise HTTPException(status_code=404, detail="No contracts found with given filters")
188
+
189
+ filename = f"contracts_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
190
+
191
+ if request.format == "excel":
192
+ # Convert to DataFrame
193
+ df = pd.DataFrame(contracts)
194
+
195
+ # Generate Excel with formatting
196
+ excel_bytes = await export_service.generate_excel(
197
+ data=df,
198
+ title="Contratos - Portal da Transparência",
199
+ metadata={
200
+ 'exported_at': datetime.now().isoformat(),
201
+ 'total_records': len(contracts),
202
+ 'filters': filters
203
+ }
204
+ )
205
+
206
+ return Response(
207
+ content=excel_bytes,
208
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
209
+ headers={
210
+ "Content-Disposition": f"attachment; filename={filename}.xlsx"
211
+ }
212
+ )
213
+
214
+ elif request.format == "csv":
215
+ df = pd.DataFrame(contracts)
216
+ csv_bytes = await export_service.generate_csv(df)
217
+
218
+ return Response(
219
+ content=csv_bytes,
220
+ media_type="text/csv",
221
+ headers={
222
+ "Content-Disposition": f"attachment; filename={filename}.csv"
223
+ }
224
+ )
225
+
226
+ else:
227
+ raise HTTPException(status_code=400, detail="Format not supported for contracts export")
228
+
229
+
230
+ @router.post("/anomalies/export")
231
+ async def export_anomalies(
232
+ request: ExportRequest,
233
+ current_user: Dict[str, Any] = Depends(get_current_user)
234
+ ):
235
+ """
236
+ Export anomaly data with filters.
237
+
238
+ Exports detected anomalies in various formats.
239
+ """
240
+ # Get anomalies from investigations
241
+ filters = request.filters or {}
242
+
243
+ # For now, get anomalies from recent investigations
244
+ # In production, this would query a dedicated anomalies table
245
+ investigations = await investigation_service.list_investigations(
246
+ user_id=current_user.get("user_id"),
247
+ status="completed",
248
+ limit=100
249
+ )
250
+
251
+ all_anomalies = []
252
+ for inv in investigations:
253
+ anomalies = inv.get('anomalies', [])
254
+ for anomaly in anomalies:
255
+ anomaly['investigation_id'] = inv['id']
256
+ all_anomalies.append(anomaly)
257
+
258
+ if not all_anomalies:
259
+ raise HTTPException(status_code=404, detail="No anomalies found")
260
+
261
+ filename = f"anomalies_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
262
+
263
+ if request.format == "excel":
264
+ df = pd.DataFrame(all_anomalies)
265
+
266
+ # Separate by severity
267
+ high_severity = df[df['severity'] >= 0.7]
268
+ medium_severity = df[(df['severity'] >= 0.4) & (df['severity'] < 0.7)]
269
+ low_severity = df[df['severity'] < 0.4]
270
+
271
+ dataframes = {
272
+ 'Alta Severidade': high_severity,
273
+ 'Média Severidade': medium_severity,
274
+ 'Baixa Severidade': low_severity,
275
+ 'Todas Anomalias': df
276
+ }
277
+
278
+ excel_bytes = await export_service.generate_excel(
279
+ data=dataframes,
280
+ title="Anomalias Detectadas - Cidadão.AI",
281
+ metadata={
282
+ 'exported_at': datetime.now().isoformat(),
283
+ 'total_anomalies': len(all_anomalies),
284
+ 'high_severity_count': len(high_severity),
285
+ 'medium_severity_count': len(medium_severity),
286
+ 'low_severity_count': len(low_severity),
287
+ }
288
+ )
289
+
290
+ return Response(
291
+ content=excel_bytes,
292
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
293
+ headers={
294
+ "Content-Disposition": f"attachment; filename={filename}.xlsx"
295
+ }
296
+ )
297
+
298
+ else:
299
+ raise HTTPException(status_code=400, detail="Format not supported for anomalies export")
300
+
301
+
302
+ @router.post("/bulk")
303
+ async def bulk_export(
304
+ request: BulkExportRequest,
305
+ current_user: Dict[str, Any] = Depends(get_current_user)
306
+ ):
307
+ """
308
+ Create bulk export with multiple files.
309
+
310
+ Generates a ZIP file containing multiple exports.
311
+ """
312
+ exports_config = []
313
+
314
+ for export in request.exports:
315
+ export_type = export.get('type')
316
+ export_format = export.get('format', 'json')
317
+
318
+ if export_type == 'investigation':
319
+ investigation = await investigation_service.get_investigation(
320
+ export['id'],
321
+ user_id=current_user.get("user_id")
322
+ )
323
+
324
+ if investigation:
325
+ if export_format == 'pdf':
326
+ content = _format_investigation_as_markdown(investigation)
327
+ exports_config.append({
328
+ 'filename': f"investigation_{export['id']}.pdf",
329
+ 'content': content,
330
+ 'format': 'pdf',
331
+ 'title': f"Investigação {export['id']}",
332
+ 'metadata': {'investigation_id': export['id']}
333
+ })
334
+ else:
335
+ exports_config.append({
336
+ 'filename': f"investigation_{export['id']}.json",
337
+ 'content': json_utils.dumps(investigation, indent=2),
338
+ 'format': 'json'
339
+ })
340
+
341
+ if not exports_config:
342
+ raise HTTPException(status_code=404, detail="No data found for bulk export")
343
+
344
+ # Generate ZIP
345
+ zip_bytes = await export_service.generate_bulk_export(exports_config)
346
+
347
+ return Response(
348
+ content=zip_bytes,
349
+ media_type="application/zip",
350
+ headers={
351
+ "Content-Disposition": f"attachment; filename=bulk_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
352
+ }
353
+ )
354
+
355
+
356
+ def _format_investigation_as_markdown(investigation: Dict[str, Any]) -> str:
357
+ """Format investigation data as markdown for PDF generation."""
358
+ lines = []
359
+
360
+ lines.append(f"# Investigação {investigation['id']}")
361
+ lines.append("")
362
+ lines.append(f"**Tipo**: {investigation.get('type', 'N/A')}")
363
+ lines.append(f"**Status**: {investigation.get('status', 'N/A')}")
364
+ lines.append(f"**Data de Criação**: {investigation.get('created_at', 'N/A')}")
365
+ lines.append("")
366
+
367
+ if investigation.get('summary'):
368
+ lines.append("## Resumo")
369
+ lines.append(investigation['summary'])
370
+ lines.append("")
371
+
372
+ anomalies = investigation.get('anomalies', [])
373
+ if anomalies:
374
+ lines.append("## Anomalias Detectadas")
375
+ lines.append("")
376
+ lines.append(f"Total de anomalias: {len(anomalies)}")
377
+ lines.append("")
378
+
379
+ for i, anomaly in enumerate(anomalies, 1):
380
+ lines.append(f"### Anomalia {i}")
381
+ lines.append(f"**Tipo**: {anomaly.get('type', 'N/A')}")
382
+ lines.append(f"**Severidade**: {anomaly.get('severity', 0):.2f}")
383
+ lines.append(f"**Descrição**: {anomaly.get('description', 'N/A')}")
384
+ lines.append(f"**Explicação**: {anomaly.get('explanation', 'N/A')}")
385
+ lines.append("")
386
+
387
+ return "\n".join(lines)
src/api/routes/reports.py CHANGED
@@ -16,7 +16,8 @@ from fastapi.responses import HTMLResponse, FileResponse
16
  from pydantic import BaseModel, Field as PydanticField, validator
17
  from src.core import json_utils
18
  from src.core import get_logger
19
- from src.agents import ReporterAgent, AgentContext
 
20
  from src.api.middleware.authentication import get_current_user
21
 
22
 
@@ -346,6 +347,34 @@ async def download_report(
346
  }
347
  )
348
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  else:
350
  raise HTTPException(status_code=400, detail="Unsupported format")
351
 
@@ -458,47 +487,56 @@ async def _generate_report(report_id: str, request: ReportRequest):
458
  report["current_phase"] = "content_generation"
459
  report["progress"] = 0.3
460
 
461
- # Generate report content based on type
462
- if request.report_type == "executive_summary":
463
- content = await reporter.generate_executive_summary(
464
- investigation_ids=request.investigation_ids,
465
- analysis_ids=request.analysis_ids,
466
- time_range=request.time_range,
467
- context=context
468
- )
469
- elif request.report_type == "detailed_analysis":
470
- content = await reporter.generate_detailed_analysis(
471
- data_sources=request.data_sources,
472
- analysis_ids=request.analysis_ids,
473
- time_range=request.time_range,
474
- context=context
475
- )
476
- elif request.report_type == "investigation_report":
477
- content = await reporter.generate_investigation_report(
478
- investigation_ids=request.investigation_ids,
479
- include_evidence=True,
480
- context=context
481
- )
482
- else:
483
- content = await reporter.generate_custom_report(
484
- report_type=request.report_type,
485
- title=request.title,
486
- data_sources=request.data_sources,
487
- investigation_ids=request.investigation_ids,
488
- analysis_ids=request.analysis_ids,
489
- context=context
490
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
491
 
492
  report["current_phase"] = "formatting"
493
  report["progress"] = 0.7
494
 
495
- # Format content according to output format
496
- if request.output_format == "html":
497
- formatted_content = await reporter.format_as_html(content, request.title)
498
- elif request.output_format == "json":
499
- formatted_content = await reporter.format_as_json(content, report)
500
- else:
501
- formatted_content = content # Keep as markdown
502
 
503
  report["current_phase"] = "finalization"
504
  report["progress"] = 0.9
 
16
  from pydantic import BaseModel, Field as PydanticField, validator
17
  from src.core import json_utils
18
  from src.core import get_logger
19
+ from src.agents.tiradentes import ReporterAgent
20
+ from src.agents import AgentContext
21
  from src.api.middleware.authentication import get_current_user
22
 
23
 
 
347
  }
348
  )
349
 
350
+ elif format == "pdf":
351
+ # Check if content is base64 encoded PDF
352
+ import base64
353
+ try:
354
+ # If content is already a base64 PDF, decode it
355
+ if report["output_format"] == "pdf":
356
+ pdf_bytes = base64.b64decode(content)
357
+ else:
358
+ # Convert markdown/html content to PDF
359
+ from src.services.export_service import export_service
360
+ pdf_bytes = await export_service.generate_pdf(
361
+ content=content,
362
+ title=report["title"],
363
+ metadata=report["metadata"],
364
+ format_type="report"
365
+ )
366
+
367
+ return Response(
368
+ content=pdf_bytes,
369
+ media_type="application/pdf",
370
+ headers={
371
+ "Content-Disposition": f"attachment; filename={title}.pdf"
372
+ }
373
+ )
374
+ except Exception as e:
375
+ logger.error("pdf_download_error", error=str(e), report_id=report_id)
376
+ raise HTTPException(status_code=500, detail="Failed to generate PDF")
377
+
378
  else:
379
  raise HTTPException(status_code=400, detail="Unsupported format")
380
 
 
487
  report["current_phase"] = "content_generation"
488
  report["progress"] = 0.3
489
 
490
+ # Create report request for Tiradentes
491
+ from src.agents.tiradentes import ReportRequest as TiradentesReportRequest, ReportType, ReportFormat
492
+
493
+ # Map report type
494
+ report_type_map = {
495
+ "executive_summary": ReportType.EXECUTIVE_SUMMARY,
496
+ "detailed_analysis": ReportType.ANALYSIS_REPORT,
497
+ "investigation_report": ReportType.INVESTIGATION_REPORT,
498
+ "transparency_dashboard": ReportType.COMBINED_REPORT,
499
+ "comparative_analysis": ReportType.TREND_ANALYSIS,
500
+ "audit_report": ReportType.INVESTIGATION_REPORT,
501
+ }
502
+
503
+ # Map format
504
+ format_map = {
505
+ "markdown": ReportFormat.MARKDOWN,
506
+ "html": ReportFormat.HTML,
507
+ "json": ReportFormat.JSON,
508
+ "pdf": ReportFormat.PDF,
509
+ }
510
+
511
+ tiradentes_request = TiradentesReportRequest(
512
+ report_type=report_type_map.get(request.report_type, ReportType.INVESTIGATION_REPORT),
513
+ format=format_map.get(request.output_format, ReportFormat.MARKDOWN),
514
+ target_audience=request.target_audience,
515
+ language="pt-BR",
516
+ )
517
+
518
+ # Process with Tiradentes
519
+ from src.agents import AgentMessage
520
+ message = AgentMessage(
521
+ agent_id=reporter.agent_id,
522
+ content={
523
+ "request": tiradentes_request,
524
+ "investigation_ids": request.investigation_ids,
525
+ "analysis_ids": request.analysis_ids,
526
+ "data_sources": request.data_sources,
527
+ "time_range": request.time_range,
528
+ },
529
+ requires_response=True
530
+ )
531
+
532
+ result = await reporter.process(message, context)
533
+ content = result.data.get("report_content", "")
534
 
535
  report["current_phase"] = "formatting"
536
  report["progress"] = 0.7
537
 
538
+ # Content is already formatted by Tiradentes based on the format requested
539
+ formatted_content = content
 
 
 
 
 
540
 
541
  report["current_phase"] = "finalization"
542
  report["progress"] = 0.9
src/services/export_service.py ADDED
@@ -0,0 +1,467 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: services.export_service
3
+ Description: Document export service for generating PDF, Excel and CSV files
4
+ Author: Anderson H. Silva
5
+ Date: 2025-01-25
6
+ License: Proprietary - All rights reserved
7
+ """
8
+
9
+ import io
10
+ import zipfile
11
+ from datetime import datetime
12
+ from typing import Dict, List, Any, Optional, Union
13
+ from pathlib import Path
14
+ import asyncio
15
+ from concurrent.futures import ThreadPoolExecutor
16
+
17
+ import pandas as pd
18
+ from reportlab.lib import colors
19
+ from reportlab.lib.pagesizes import letter, A4
20
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
21
+ from reportlab.lib.units import inch
22
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image
23
+ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
24
+ import markdown
25
+ from bs4 import BeautifulSoup
26
+ import openpyxl
27
+ from openpyxl.styles import Font, PatternFill, Alignment
28
+ from openpyxl.utils import get_column_letter
29
+
30
+ from src.core import get_logger
31
+ from src.core import json_utils
32
+
33
+ logger = get_logger(__name__)
34
+
35
+ # Thread pool for CPU-intensive PDF generation
36
+ _pdf_thread_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="pdf_export")
37
+
38
+
39
+ class ExportService:
40
+ """Service for exporting documents in various formats."""
41
+
42
+ def __init__(self):
43
+ """Initialize export service."""
44
+ self.styles = getSampleStyleSheet()
45
+ self._create_custom_styles()
46
+
47
+ def _create_custom_styles(self):
48
+ """Create custom PDF styles."""
49
+ # Title style
50
+ self.styles.add(ParagraphStyle(
51
+ name='CustomTitle',
52
+ parent=self.styles['Title'],
53
+ fontSize=24,
54
+ textColor=colors.HexColor('#1a73e8'),
55
+ spaceAfter=30,
56
+ alignment=TA_CENTER
57
+ ))
58
+
59
+ # Subtitle style
60
+ self.styles.add(ParagraphStyle(
61
+ name='CustomSubtitle',
62
+ parent=self.styles['Heading2'],
63
+ fontSize=16,
64
+ textColor=colors.HexColor('#34495e'),
65
+ spaceBefore=20,
66
+ spaceAfter=10
67
+ ))
68
+
69
+ # Body text style
70
+ self.styles.add(ParagraphStyle(
71
+ name='CustomBody',
72
+ parent=self.styles['BodyText'],
73
+ fontSize=11,
74
+ leading=16,
75
+ alignment=TA_JUSTIFY,
76
+ spaceBefore=6,
77
+ spaceAfter=6
78
+ ))
79
+
80
+ # Footer style
81
+ self.styles.add(ParagraphStyle(
82
+ name='CustomFooter',
83
+ parent=self.styles['Normal'],
84
+ fontSize=9,
85
+ textColor=colors.grey,
86
+ alignment=TA_CENTER
87
+ ))
88
+
89
+ async def generate_pdf(
90
+ self,
91
+ content: str,
92
+ title: str,
93
+ metadata: Optional[Dict[str, Any]] = None,
94
+ format_type: str = "report"
95
+ ) -> bytes:
96
+ """
97
+ Generate PDF from content.
98
+
99
+ Args:
100
+ content: Content in markdown format
101
+ title: Document title
102
+ metadata: Additional metadata
103
+ format_type: Type of document (report, investigation, analysis)
104
+
105
+ Returns:
106
+ PDF bytes
107
+ """
108
+ # Run PDF generation in thread pool to avoid blocking
109
+ loop = asyncio.get_event_loop()
110
+ return await loop.run_in_executor(
111
+ _pdf_thread_pool,
112
+ self._generate_pdf_sync,
113
+ content,
114
+ title,
115
+ metadata or {},
116
+ format_type
117
+ )
118
+
119
+ def _generate_pdf_sync(
120
+ self,
121
+ content: str,
122
+ title: str,
123
+ metadata: Dict[str, Any],
124
+ format_type: str
125
+ ) -> bytes:
126
+ """Synchronous PDF generation."""
127
+ # Create buffer
128
+ buffer = io.BytesIO()
129
+
130
+ # Create PDF
131
+ doc = SimpleDocTemplate(
132
+ buffer,
133
+ pagesize=A4,
134
+ rightMargin=72,
135
+ leftMargin=72,
136
+ topMargin=72,
137
+ bottomMargin=48
138
+ )
139
+
140
+ # Build story
141
+ story = []
142
+
143
+ # Add header with logo/branding
144
+ story.append(Paragraph("Cidadão.AI - Transparência Governamental", self.styles['CustomFooter']))
145
+ story.append(Spacer(1, 0.2*inch))
146
+
147
+ # Add title
148
+ story.append(Paragraph(title, self.styles['CustomTitle']))
149
+
150
+ # Add metadata if provided
151
+ if metadata:
152
+ meta_data = []
153
+ if 'generated_at' in metadata:
154
+ meta_data.append(f"Gerado em: {metadata['generated_at']}")
155
+ if 'report_type' in metadata:
156
+ meta_data.append(f"Tipo: {metadata['report_type']}")
157
+ if 'author' in metadata:
158
+ meta_data.append(f"Autor: {metadata['author']}")
159
+
160
+ if meta_data:
161
+ story.append(Paragraph(" | ".join(meta_data), self.styles['CustomFooter']))
162
+ story.append(Spacer(1, 0.3*inch))
163
+
164
+ # Convert markdown to HTML
165
+ html_content = markdown.markdown(
166
+ content,
167
+ extensions=['extra', 'codehilite', 'toc', 'tables']
168
+ )
169
+
170
+ # Parse HTML and convert to PDF elements
171
+ soup = BeautifulSoup(html_content, 'html.parser')
172
+
173
+ for element in soup.find_all():
174
+ if element.name == 'h1':
175
+ story.append(PageBreak())
176
+ story.append(Paragraph(element.text, self.styles['Heading1']))
177
+ elif element.name == 'h2':
178
+ story.append(Spacer(1, 0.2*inch))
179
+ story.append(Paragraph(element.text, self.styles['CustomSubtitle']))
180
+ elif element.name == 'h3':
181
+ story.append(Spacer(1, 0.15*inch))
182
+ story.append(Paragraph(element.text, self.styles['Heading3']))
183
+ elif element.name == 'p':
184
+ story.append(Paragraph(element.text, self.styles['CustomBody']))
185
+ elif element.name == 'ul':
186
+ for li in element.find_all('li'):
187
+ story.append(Paragraph(f"• {li.text}", self.styles['CustomBody']))
188
+ elif element.name == 'ol':
189
+ for i, li in enumerate(element.find_all('li'), 1):
190
+ story.append(Paragraph(f"{i}. {li.text}", self.styles['CustomBody']))
191
+ elif element.name == 'table':
192
+ # Convert HTML table to ReportLab table
193
+ table_data = []
194
+ rows = element.find_all('tr')
195
+
196
+ for row in rows:
197
+ cells = row.find_all(['td', 'th'])
198
+ table_data.append([cell.text.strip() for cell in cells])
199
+
200
+ if table_data:
201
+ t = Table(table_data)
202
+ t.setStyle(TableStyle([
203
+ ('BACKGROUND', (0, 0), (-1, 0), colors.grey),
204
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
205
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
206
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
207
+ ('FONTSIZE', (0, 0), (-1, 0), 12),
208
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
209
+ ('BACKGROUND', (0, 1), (-1, -1), colors.beige),
210
+ ('GRID', (0, 0), (-1, -1), 1, colors.black)
211
+ ]))
212
+ story.append(Spacer(1, 0.1*inch))
213
+ story.append(t)
214
+ story.append(Spacer(1, 0.1*inch))
215
+
216
+ # Add footer
217
+ story.append(Spacer(1, 0.5*inch))
218
+ story.append(Paragraph(
219
+ f"Documento gerado automaticamente pelo Cidadão.AI em {datetime.now().strftime('%d/%m/%Y %H:%M')}",
220
+ self.styles['CustomFooter']
221
+ ))
222
+
223
+ # Build PDF
224
+ doc.build(story)
225
+
226
+ # Get PDF bytes
227
+ pdf_bytes = buffer.getvalue()
228
+ buffer.close()
229
+
230
+ return pdf_bytes
231
+
232
+ async def generate_excel(
233
+ self,
234
+ data: Union[Dict[str, pd.DataFrame], pd.DataFrame],
235
+ title: str,
236
+ metadata: Optional[Dict[str, Any]] = None
237
+ ) -> bytes:
238
+ """
239
+ Generate Excel file from data.
240
+
241
+ Args:
242
+ data: DataFrame or dict of DataFrames (for multiple sheets)
243
+ title: Document title
244
+ metadata: Additional metadata
245
+
246
+ Returns:
247
+ Excel bytes
248
+ """
249
+ buffer = io.BytesIO()
250
+
251
+ # Create Excel writer
252
+ with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
253
+ # Handle single or multiple DataFrames
254
+ if isinstance(data, pd.DataFrame):
255
+ data = {'Dados': data}
256
+
257
+ # Write each DataFrame to a sheet
258
+ for sheet_name, df in data.items():
259
+ df.to_excel(writer, sheet_name=sheet_name[:31], index=False) # Excel sheet name limit
260
+
261
+ # Get the worksheet
262
+ worksheet = writer.sheets[sheet_name]
263
+
264
+ # Apply formatting
265
+ self._format_excel_sheet(worksheet, title, metadata)
266
+
267
+ # Add metadata sheet
268
+ if metadata:
269
+ meta_df = pd.DataFrame([
270
+ {'Campo': k, 'Valor': str(v)}
271
+ for k, v in metadata.items()
272
+ ])
273
+ meta_df.to_excel(writer, sheet_name='Metadados', index=False)
274
+ self._format_excel_sheet(writer.sheets['Metadados'], 'Metadados', {})
275
+
276
+ return buffer.getvalue()
277
+
278
+ def _format_excel_sheet(self, worksheet, title: str, metadata: Dict[str, Any]):
279
+ """Apply formatting to Excel worksheet."""
280
+ # Set column widths
281
+ for column_cells in worksheet.columns:
282
+ length = max(len(str(cell.value or '')) for cell in column_cells)
283
+ worksheet.column_dimensions[column_cells[0].column_letter].width = min(length + 2, 50)
284
+
285
+ # Add title row
286
+ worksheet.insert_rows(1)
287
+ worksheet.merge_cells('A1:' + get_column_letter(worksheet.max_column) + '1')
288
+ title_cell = worksheet['A1']
289
+ title_cell.value = title
290
+ title_cell.font = Font(size=16, bold=True, color="1a73e8")
291
+ title_cell.alignment = Alignment(horizontal='center', vertical='center')
292
+
293
+ # Add generation date
294
+ worksheet.insert_rows(2)
295
+ worksheet.merge_cells('A2:' + get_column_letter(worksheet.max_column) + '2')
296
+ date_cell = worksheet['A2']
297
+ date_cell.value = f"Gerado em: {datetime.now().strftime('%d/%m/%Y %H:%M')}"
298
+ date_cell.font = Font(size=10, italic=True)
299
+ date_cell.alignment = Alignment(horizontal='center')
300
+
301
+ # Format headers
302
+ header_fill = PatternFill(start_color="4285F4", end_color="4285F4", fill_type="solid")
303
+ header_font = Font(bold=True, color="FFFFFF")
304
+
305
+ for cell in worksheet[4]: # Assuming headers are now in row 4
306
+ if cell.value:
307
+ cell.fill = header_fill
308
+ cell.font = header_font
309
+ cell.alignment = Alignment(horizontal='center')
310
+
311
+ # Add borders
312
+ from openpyxl.styles import Border, Side
313
+ thin_border = Border(
314
+ left=Side(style='thin'),
315
+ right=Side(style='thin'),
316
+ top=Side(style='thin'),
317
+ bottom=Side(style='thin')
318
+ )
319
+
320
+ for row in worksheet.iter_rows(min_row=4):
321
+ for cell in row:
322
+ if cell.value:
323
+ cell.border = thin_border
324
+
325
+ async def generate_csv(
326
+ self,
327
+ data: pd.DataFrame,
328
+ encoding: str = 'utf-8'
329
+ ) -> bytes:
330
+ """
331
+ Generate CSV file from DataFrame.
332
+
333
+ Args:
334
+ data: DataFrame to export
335
+ encoding: File encoding
336
+
337
+ Returns:
338
+ CSV bytes
339
+ """
340
+ return data.to_csv(index=False).encode(encoding)
341
+
342
+ async def generate_bulk_export(
343
+ self,
344
+ exports: List[Dict[str, Any]],
345
+ format: str = "zip"
346
+ ) -> bytes:
347
+ """
348
+ Generate bulk export with multiple files.
349
+
350
+ Args:
351
+ exports: List of export configurations
352
+ Each dict should have: 'filename', 'content', 'format'
353
+ format: Archive format (zip)
354
+
355
+ Returns:
356
+ Archive bytes
357
+ """
358
+ if format != "zip":
359
+ raise ValueError("Currently only ZIP format is supported for bulk exports")
360
+
361
+ buffer = io.BytesIO()
362
+
363
+ with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zipf:
364
+ for export in exports:
365
+ filename = export['filename']
366
+ content = export['content']
367
+ file_format = export.get('format', 'txt')
368
+
369
+ # Generate file based on format
370
+ if file_format == 'pdf':
371
+ file_bytes = await self.generate_pdf(
372
+ content=content,
373
+ title=export.get('title', filename),
374
+ metadata=export.get('metadata', {})
375
+ )
376
+ elif file_format == 'excel':
377
+ file_bytes = await self.generate_excel(
378
+ data=export.get('data', pd.DataFrame()),
379
+ title=export.get('title', filename),
380
+ metadata=export.get('metadata', {})
381
+ )
382
+ elif file_format == 'csv':
383
+ file_bytes = await self.generate_csv(
384
+ data=export.get('data', pd.DataFrame())
385
+ )
386
+ else:
387
+ # Default to text
388
+ file_bytes = content.encode('utf-8')
389
+
390
+ # Add to zip
391
+ zipf.writestr(filename, file_bytes)
392
+
393
+ logger.info(
394
+ "bulk_export_file_added",
395
+ filename=filename,
396
+ format=file_format,
397
+ size=len(file_bytes)
398
+ )
399
+
400
+ return buffer.getvalue()
401
+
402
+ async def convert_investigation_to_excel(
403
+ self,
404
+ investigation_data: Dict[str, Any]
405
+ ) -> bytes:
406
+ """
407
+ Convert investigation data to Excel format.
408
+
409
+ Args:
410
+ investigation_data: Investigation data dict
411
+
412
+ Returns:
413
+ Excel bytes
414
+ """
415
+ # Create multiple DataFrames for different aspects
416
+ dataframes = {}
417
+
418
+ # Summary sheet
419
+ summary_data = {
420
+ 'Campo': ['ID', 'Tipo', 'Status', 'Data Início', 'Data Fim', 'Duração (min)'],
421
+ 'Valor': [
422
+ investigation_data.get('id', 'N/A'),
423
+ investigation_data.get('type', 'N/A'),
424
+ investigation_data.get('status', 'N/A'),
425
+ investigation_data.get('created_at', 'N/A'),
426
+ investigation_data.get('completed_at', 'N/A'),
427
+ investigation_data.get('duration_minutes', 'N/A')
428
+ ]
429
+ }
430
+ dataframes['Resumo'] = pd.DataFrame(summary_data)
431
+
432
+ # Anomalies sheet
433
+ anomalies = investigation_data.get('anomalies', [])
434
+ if anomalies:
435
+ anomaly_df = pd.DataFrame(anomalies)
436
+ dataframes['Anomalias'] = anomaly_df
437
+
438
+ # Contracts sheet
439
+ contracts = investigation_data.get('contracts', [])
440
+ if contracts:
441
+ contract_df = pd.DataFrame(contracts)
442
+ dataframes['Contratos'] = contract_df
443
+
444
+ # Analysis results
445
+ results = investigation_data.get('results', {})
446
+ if results:
447
+ results_data = []
448
+ for key, value in results.items():
449
+ results_data.append({
450
+ 'Métrica': key,
451
+ 'Valor': str(value)
452
+ })
453
+ dataframes['Resultados'] = pd.DataFrame(results_data)
454
+
455
+ # Generate Excel
456
+ return await self.generate_excel(
457
+ data=dataframes,
458
+ title=f"Investigação {investigation_data.get('id', 'N/A')}",
459
+ metadata={
460
+ 'generated_at': datetime.now().isoformat(),
461
+ 'investigation_id': investigation_data.get('id', 'N/A')
462
+ }
463
+ )
464
+
465
+
466
+ # Global instance
467
+ export_service = ExportService()
tests/unit/agents/test_tiradentes_pdf.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: tests.unit.agents.test_tiradentes_pdf
3
+ Description: Unit tests for Tiradentes PDF generation functionality
4
+ Author: Anderson H. Silva
5
+ Date: 2025-01-25
6
+ License: Proprietary - All rights reserved
7
+ """
8
+
9
+ import pytest
10
+ import base64
11
+ from datetime import datetime
12
+ from unittest.mock import AsyncMock, MagicMock, patch
13
+
14
+ from src.agents.tiradentes import (
15
+ ReporterAgent, ReportRequest, ReportFormat, ReportType,
16
+ ReportSection, AgentContext
17
+ )
18
+
19
+
20
+ class TestTiradentePDFGeneration:
21
+ """Test suite for Tiradentes PDF generation."""
22
+
23
+ @pytest.fixture
24
+ def reporter_agent(self):
25
+ """Create ReporterAgent instance."""
26
+ return ReporterAgent()
27
+
28
+ @pytest.fixture
29
+ def agent_context(self):
30
+ """Create agent context for testing."""
31
+ return AgentContext(
32
+ conversation_id="test-conv-001",
33
+ user_id="test-user-123",
34
+ investigation_id="INV-001",
35
+ session_data={}
36
+ )
37
+
38
+ @pytest.fixture
39
+ def sample_sections(self):
40
+ """Create sample report sections."""
41
+ return [
42
+ ReportSection(
43
+ title="Resumo Executivo",
44
+ content="Este relatório apresenta as principais descobertas da investigação.",
45
+ importance=5,
46
+ subsections=None,
47
+ charts=None,
48
+ tables=None
49
+ ),
50
+ ReportSection(
51
+ title="Anomalias Detectadas",
52
+ content="""
53
+ ## Anomalia 1: Valor Atípico
54
+ - **Severidade**: 0.85
55
+ - **Descrição**: Contrato com valor 300% acima da média
56
+ - **Recomendação**: Investigação detalhada necessária
57
+
58
+ ## Anomalia 2: Padrão Temporal
59
+ - **Severidade**: 0.72
60
+ - **Descrição**: Concentração anormal de contratos
61
+ """,
62
+ importance=4,
63
+ subsections=None,
64
+ charts=None,
65
+ tables=None
66
+ ),
67
+ ReportSection(
68
+ title="Conclusões",
69
+ content="As anomalias identificadas sugerem possíveis irregularidades.",
70
+ importance=3,
71
+ subsections=None,
72
+ charts=None,
73
+ tables=None
74
+ )
75
+ ]
76
+
77
+ @pytest.mark.asyncio
78
+ @patch('src.agents.tiradentes.export_service')
79
+ async def test_render_pdf_basic(
80
+ self, mock_export_service, reporter_agent, sample_sections, agent_context
81
+ ):
82
+ """Test basic PDF rendering."""
83
+ # Setup mock
84
+ mock_export_service.generate_pdf.return_value = b'mock-pdf-content'
85
+
86
+ # Create request
87
+ request = ReportRequest(
88
+ report_type=ReportType.INVESTIGATION_REPORT,
89
+ format=ReportFormat.PDF,
90
+ target_audience="technical",
91
+ language="pt-BR"
92
+ )
93
+
94
+ # Render PDF
95
+ result = await reporter_agent._render_pdf(
96
+ sections=sample_sections,
97
+ request=request,
98
+ context=agent_context
99
+ )
100
+
101
+ # Verify result is base64 encoded
102
+ assert isinstance(result, str)
103
+ decoded = base64.b64decode(result)
104
+ assert decoded == b'mock-pdf-content'
105
+
106
+ # Verify export service was called
107
+ mock_export_service.generate_pdf.assert_called_once()
108
+ call_args = mock_export_service.generate_pdf.call_args
109
+
110
+ # Check arguments
111
+ assert "Este relatório apresenta" in call_args[1]['content']
112
+ assert call_args[1]['title'] == "Relatório: Investigation Report"
113
+ assert call_args[1]['metadata']['report_type'] == 'investigation_report'
114
+ assert call_args[1]['metadata']['investigation_id'] == 'INV-001'
115
+
116
+ @pytest.mark.asyncio
117
+ @patch('src.agents.tiradentes.export_service')
118
+ async def test_render_pdf_with_metadata(
119
+ self, mock_export_service, reporter_agent, sample_sections, agent_context
120
+ ):
121
+ """Test PDF rendering with full metadata."""
122
+ # Setup mock
123
+ mock_export_service.generate_pdf.return_value = b'pdf-with-metadata'
124
+
125
+ # Create request
126
+ request = ReportRequest(
127
+ report_type=ReportType.EXECUTIVE_SUMMARY,
128
+ format=ReportFormat.PDF,
129
+ target_audience="executive",
130
+ language="pt-BR"
131
+ )
132
+
133
+ # Render PDF
134
+ result = await reporter_agent._render_pdf(
135
+ sections=sample_sections,
136
+ request=request,
137
+ context=agent_context
138
+ )
139
+
140
+ # Verify metadata passed to export service
141
+ call_metadata = mock_export_service.generate_pdf.call_args[1]['metadata']
142
+ assert call_metadata['target_audience'] == 'executive'
143
+ assert call_metadata['author'] == 'Agente Tiradentes - Cidadão.AI'
144
+ assert 'generated_at' in call_metadata
145
+
146
+ @pytest.mark.asyncio
147
+ @patch('src.agents.tiradentes.export_service')
148
+ async def test_render_pdf_format_type(
149
+ self, mock_export_service, reporter_agent, sample_sections, agent_context
150
+ ):
151
+ """Test PDF rendering passes correct format type."""
152
+ # Setup mock
153
+ mock_export_service.generate_pdf.return_value = b'typed-pdf'
154
+
155
+ # Create request
156
+ request = ReportRequest(
157
+ report_type=ReportType.TREND_ANALYSIS,
158
+ format=ReportFormat.PDF,
159
+ target_audience="researcher",
160
+ language="pt-BR"
161
+ )
162
+
163
+ # Render PDF
164
+ await reporter_agent._render_pdf(
165
+ sections=sample_sections,
166
+ request=request,
167
+ context=agent_context
168
+ )
169
+
170
+ # Verify format_type parameter
171
+ assert mock_export_service.generate_pdf.call_args[1]['format_type'] == "report"
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_pdf_format_in_renderers(self, reporter_agent):
175
+ """Test PDF format is registered in format renderers."""
176
+ assert ReportFormat.PDF in reporter_agent.format_renderers
177
+ assert reporter_agent.format_renderers[ReportFormat.PDF] == reporter_agent._render_pdf
178
+
179
+ @pytest.mark.asyncio
180
+ @patch('src.agents.tiradentes.export_service')
181
+ async def test_render_pdf_markdown_conversion(
182
+ self, mock_export_service, reporter_agent, agent_context
183
+ ):
184
+ """Test PDF rendering converts sections to markdown first."""
185
+ # Setup mock to capture markdown content
186
+ markdown_content = None
187
+
188
+ async def capture_markdown(*args, **kwargs):
189
+ nonlocal markdown_content
190
+ markdown_content = kwargs['content']
191
+ return b'test-pdf'
192
+
193
+ mock_export_service.generate_pdf.side_effect = capture_markdown
194
+
195
+ # Create simple sections
196
+ sections = [
197
+ ReportSection(
198
+ title="Test Section",
199
+ content="Test content",
200
+ importance=5
201
+ )
202
+ ]
203
+
204
+ request = ReportRequest(
205
+ report_type=ReportType.INVESTIGATION_REPORT,
206
+ format=ReportFormat.PDF,
207
+ target_audience="general",
208
+ language="pt-BR"
209
+ )
210
+
211
+ # Render PDF
212
+ await reporter_agent._render_pdf(sections, request, agent_context)
213
+
214
+ # Verify markdown was generated
215
+ assert markdown_content is not None
216
+ assert "# Relatório: Investigation Report" in markdown_content
217
+ assert "## Test Section" in markdown_content
218
+ assert "Test content" in markdown_content
219
+
220
+ @pytest.mark.asyncio
221
+ @patch('src.agents.tiradentes.export_service')
222
+ async def test_render_pdf_error_handling(
223
+ self, mock_export_service, reporter_agent, sample_sections, agent_context
224
+ ):
225
+ """Test PDF rendering error handling."""
226
+ # Setup mock to raise exception
227
+ mock_export_service.generate_pdf.side_effect = Exception("PDF generation failed")
228
+
229
+ request = ReportRequest(
230
+ report_type=ReportType.INVESTIGATION_REPORT,
231
+ format=ReportFormat.PDF,
232
+ target_audience="technical",
233
+ language="pt-BR"
234
+ )
235
+
236
+ # Should raise exception
237
+ with pytest.raises(Exception) as exc_info:
238
+ await reporter_agent._render_pdf(
239
+ sections=sample_sections,
240
+ request=request,
241
+ context=agent_context
242
+ )
243
+
244
+ assert "PDF generation failed" in str(exc_info.value)
245
+
246
+ @pytest.mark.asyncio
247
+ @patch('src.agents.tiradentes.export_service')
248
+ async def test_render_pdf_empty_sections(
249
+ self, mock_export_service, reporter_agent, agent_context
250
+ ):
251
+ """Test PDF rendering with empty sections."""
252
+ # Setup mock
253
+ mock_export_service.generate_pdf.return_value = b'empty-pdf'
254
+
255
+ request = ReportRequest(
256
+ report_type=ReportType.INVESTIGATION_REPORT,
257
+ format=ReportFormat.PDF,
258
+ target_audience="general",
259
+ language="pt-BR"
260
+ )
261
+
262
+ # Render with empty sections
263
+ result = await reporter_agent._render_pdf(
264
+ sections=[],
265
+ request=request,
266
+ context=agent_context
267
+ )
268
+
269
+ # Should still generate PDF
270
+ assert isinstance(result, str)
271
+ decoded = base64.b64decode(result)
272
+ assert decoded == b'empty-pdf'
273
+
274
+ @pytest.mark.asyncio
275
+ @patch('src.agents.tiradentes.export_service')
276
+ async def test_render_pdf_large_content(
277
+ self, mock_export_service, reporter_agent, agent_context
278
+ ):
279
+ """Test PDF rendering with large content."""
280
+ # Create large sections
281
+ large_sections = []
282
+ for i in range(10):
283
+ large_sections.append(
284
+ ReportSection(
285
+ title=f"Section {i}",
286
+ content="Very long content. " * 100,
287
+ importance=3
288
+ )
289
+ )
290
+
291
+ # Setup mock
292
+ mock_export_service.generate_pdf.return_value = b'large-pdf'
293
+
294
+ request = ReportRequest(
295
+ report_type=ReportType.INVESTIGATION_REPORT,
296
+ format=ReportFormat.PDF,
297
+ target_audience="technical",
298
+ language="pt-BR"
299
+ )
300
+
301
+ # Render PDF
302
+ result = await reporter_agent._render_pdf(
303
+ sections=large_sections,
304
+ request=request,
305
+ context=agent_context
306
+ )
307
+
308
+ # Verify it handles large content
309
+ assert isinstance(result, str)
310
+ call_content = mock_export_service.generate_pdf.call_args[1]['content']
311
+ assert len(call_content) > 10000 # Should be quite large
tests/unit/api/routes/test_export.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: tests.unit.api.routes.test_export
3
+ Description: Unit tests for export routes
4
+ Author: Anderson H. Silva
5
+ Date: 2025-01-25
6
+ License: Proprietary - All rights reserved
7
+ """
8
+
9
+ import pytest
10
+ from unittest.mock import AsyncMock, MagicMock, patch
11
+ from datetime import datetime
12
+ import pandas as pd
13
+ import base64
14
+ from fastapi import HTTPException
15
+ from fastapi.testclient import TestClient
16
+
17
+ from src.api.routes.export import (
18
+ export_investigation, export_contracts, export_anomalies,
19
+ bulk_export, _format_investigation_as_markdown
20
+ )
21
+
22
+
23
+ class TestExportRoutes:
24
+ """Test suite for export API routes."""
25
+
26
+ @pytest.fixture
27
+ def mock_current_user(self):
28
+ """Mock current user."""
29
+ return {
30
+ 'user_id': 'test-user-123',
31
+ 'email': '[email protected]',
32
+ 'roles': ['user']
33
+ }
34
+
35
+ @pytest.fixture
36
+ def mock_investigation(self):
37
+ """Mock investigation data."""
38
+ return {
39
+ 'id': 'INV-001',
40
+ 'type': 'contract_analysis',
41
+ 'status': 'completed',
42
+ 'created_at': '2024-01-20T10:00:00',
43
+ 'completed_at': '2024-01-20T10:30:00',
44
+ 'summary': 'Investigation completed successfully',
45
+ 'anomalies': [
46
+ {
47
+ 'type': 'value_outlier',
48
+ 'severity': 0.85,
49
+ 'description': 'High value contract',
50
+ 'explanation': 'Contract value exceeds threshold'
51
+ }
52
+ ],
53
+ 'contracts': [
54
+ {
55
+ 'id': 'C001',
56
+ 'value': 500000,
57
+ 'supplier': 'Company A',
58
+ 'date': '2024-01-15'
59
+ }
60
+ ],
61
+ 'total_value': 500000
62
+ }
63
+
64
+ @pytest.mark.asyncio
65
+ @patch('src.api.routes.export.investigation_service')
66
+ @patch('src.api.routes.export.export_service')
67
+ async def test_export_investigation_excel(
68
+ self, mock_export_service, mock_investigation_service,
69
+ mock_current_user, mock_investigation
70
+ ):
71
+ """Test export investigation as Excel."""
72
+ # Setup mocks
73
+ mock_investigation_service.get_investigation.return_value = mock_investigation
74
+ mock_export_service.convert_investigation_to_excel.return_value = b'excel-content'
75
+
76
+ # Call function
77
+ response = await export_investigation(
78
+ investigation_id='INV-001',
79
+ format='excel',
80
+ current_user=mock_current_user
81
+ )
82
+
83
+ # Verify
84
+ assert response.body == b'excel-content'
85
+ assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
86
+ assert 'attachment' in response.headers['Content-Disposition']
87
+ assert '.xlsx' in response.headers['Content-Disposition']
88
+
89
+ mock_investigation_service.get_investigation.assert_called_once_with(
90
+ 'INV-001',
91
+ user_id='test-user-123'
92
+ )
93
+
94
+ @pytest.mark.asyncio
95
+ @patch('src.api.routes.export.investigation_service')
96
+ @patch('src.api.routes.export.export_service')
97
+ async def test_export_investigation_csv(
98
+ self, mock_export_service, mock_investigation_service,
99
+ mock_current_user, mock_investigation
100
+ ):
101
+ """Test export investigation as CSV."""
102
+ # Setup mocks
103
+ mock_investigation_service.get_investigation.return_value = mock_investigation
104
+ mock_export_service.generate_csv.return_value = b'csv-content'
105
+
106
+ # Call function
107
+ response = await export_investigation(
108
+ investigation_id='INV-001',
109
+ format='csv',
110
+ current_user=mock_current_user
111
+ )
112
+
113
+ # Verify
114
+ assert response.body == b'csv-content'
115
+ assert response.media_type == "text/csv"
116
+ assert '.csv' in response.headers['Content-Disposition']
117
+
118
+ @pytest.mark.asyncio
119
+ @patch('src.api.routes.export.investigation_service')
120
+ @patch('src.api.routes.export.export_service')
121
+ async def test_export_investigation_pdf(
122
+ self, mock_export_service, mock_investigation_service,
123
+ mock_current_user, mock_investigation
124
+ ):
125
+ """Test export investigation as PDF."""
126
+ # Setup mocks
127
+ mock_investigation_service.get_investigation.return_value = mock_investigation
128
+ mock_export_service.generate_pdf.return_value = b'pdf-content'
129
+
130
+ # Call function
131
+ response = await export_investigation(
132
+ investigation_id='INV-001',
133
+ format='pdf',
134
+ current_user=mock_current_user
135
+ )
136
+
137
+ # Verify
138
+ assert response.body == b'pdf-content'
139
+ assert response.media_type == "application/pdf"
140
+ assert '.pdf' in response.headers['Content-Disposition']
141
+
142
+ # Check PDF generation was called with correct params
143
+ mock_export_service.generate_pdf.assert_called_once()
144
+ call_args = mock_export_service.generate_pdf.call_args[1]
145
+ assert 'Investigação INV-001' in call_args['title']
146
+ assert call_args['metadata']['investigation_id'] == 'INV-001'
147
+
148
+ @pytest.mark.asyncio
149
+ @patch('src.api.routes.export.investigation_service')
150
+ async def test_export_investigation_not_found(
151
+ self, mock_investigation_service, mock_current_user
152
+ ):
153
+ """Test export investigation when not found."""
154
+ # Setup mock
155
+ mock_investigation_service.get_investigation.return_value = None
156
+
157
+ # Call function and expect exception
158
+ with pytest.raises(HTTPException) as exc_info:
159
+ await export_investigation(
160
+ investigation_id='INV-999',
161
+ format='excel',
162
+ current_user=mock_current_user
163
+ )
164
+
165
+ assert exc_info.value.status_code == 404
166
+ assert exc_info.value.detail == "Investigation not found"
167
+
168
+ @pytest.mark.asyncio
169
+ @patch('src.api.routes.export.data_service')
170
+ @patch('src.api.routes.export.export_service')
171
+ async def test_export_contracts_excel(
172
+ self, mock_export_service, mock_data_service, mock_current_user
173
+ ):
174
+ """Test export contracts as Excel."""
175
+ from src.api.routes.export import ExportRequest
176
+
177
+ # Setup mocks
178
+ mock_contracts = [
179
+ {'id': 'C001', 'value': 100000, 'supplier': 'Company A'},
180
+ {'id': 'C002', 'value': 200000, 'supplier': 'Company B'}
181
+ ]
182
+ mock_data_service.search_contracts.return_value = mock_contracts
183
+ mock_export_service.generate_excel.return_value = b'excel-content'
184
+
185
+ # Create request
186
+ request = ExportRequest(
187
+ export_type='contracts',
188
+ format='excel',
189
+ filters={'year': 2024},
190
+ include_metadata=True,
191
+ compress=False
192
+ )
193
+
194
+ # Call function
195
+ response = await export_contracts(request, mock_current_user)
196
+
197
+ # Verify
198
+ assert response.body == b'excel-content'
199
+ assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
200
+
201
+ # Check search was called with filters
202
+ mock_data_service.search_contracts.assert_called_once()
203
+ call_kwargs = mock_data_service.search_contracts.call_args[1]
204
+ assert call_kwargs['year'] == 2024
205
+ assert call_kwargs['limit'] == 10000
206
+
207
+ @pytest.mark.asyncio
208
+ @patch('src.api.routes.export.investigation_service')
209
+ @patch('src.api.routes.export.export_service')
210
+ async def test_export_anomalies_excel(
211
+ self, mock_export_service, mock_investigation_service, mock_current_user
212
+ ):
213
+ """Test export anomalies as Excel."""
214
+ from src.api.routes.export import ExportRequest
215
+
216
+ # Setup mocks
217
+ mock_investigations = [
218
+ {
219
+ 'id': 'INV-001',
220
+ 'anomalies': [
221
+ {'severity': 0.8, 'type': 'high', 'description': 'High risk'},
222
+ {'severity': 0.5, 'type': 'medium', 'description': 'Medium risk'}
223
+ ]
224
+ },
225
+ {
226
+ 'id': 'INV-002',
227
+ 'anomalies': [
228
+ {'severity': 0.3, 'type': 'low', 'description': 'Low risk'}
229
+ ]
230
+ }
231
+ ]
232
+ mock_investigation_service.list_investigations.return_value = mock_investigations
233
+ mock_export_service.generate_excel.return_value = b'excel-content'
234
+
235
+ # Create request
236
+ request = ExportRequest(
237
+ export_type='anomalies',
238
+ format='excel',
239
+ filters={},
240
+ include_metadata=True,
241
+ compress=False
242
+ )
243
+
244
+ # Call function
245
+ response = await export_anomalies(request, mock_current_user)
246
+
247
+ # Verify
248
+ assert response.body == b'excel-content'
249
+
250
+ # Check Excel generation was called with multiple sheets
251
+ mock_export_service.generate_excel.assert_called_once()
252
+ call_args = mock_export_service.generate_excel.call_args[1]
253
+ data_arg = call_args['data']
254
+
255
+ # Should have sheets for different severity levels
256
+ assert 'Alta Severidade' in data_arg
257
+ assert 'Média Severidade' in data_arg
258
+ assert 'Baixa Severidade' in data_arg
259
+ assert 'Todas Anomalias' in data_arg
260
+
261
+ @pytest.mark.asyncio
262
+ @patch('src.api.routes.export.investigation_service')
263
+ @patch('src.api.routes.export.export_service')
264
+ @patch('src.api.routes.export.json_utils')
265
+ async def test_bulk_export(
266
+ self, mock_json_utils, mock_export_service,
267
+ mock_investigation_service, mock_current_user, mock_investigation
268
+ ):
269
+ """Test bulk export functionality."""
270
+ from src.api.routes.export import BulkExportRequest
271
+
272
+ # Setup mocks
273
+ mock_investigation_service.get_investigation.return_value = mock_investigation
274
+ mock_export_service.generate_bulk_export.return_value = b'zip-content'
275
+ mock_json_utils.dumps.return_value = '{"test": "json"}'
276
+
277
+ # Create request
278
+ request = BulkExportRequest(
279
+ exports=[
280
+ {'type': 'investigation', 'id': 'INV-001', 'format': 'pdf'},
281
+ {'type': 'investigation', 'id': 'INV-002', 'format': 'json'}
282
+ ],
283
+ compress=True
284
+ )
285
+
286
+ # Call function
287
+ response = await bulk_export(request, mock_current_user)
288
+
289
+ # Verify
290
+ assert response.body == b'zip-content'
291
+ assert response.media_type == "application/zip"
292
+ assert '.zip' in response.headers['Content-Disposition']
293
+
294
+ # Check bulk export was called
295
+ mock_export_service.generate_bulk_export.assert_called_once()
296
+ exports_config = mock_export_service.generate_bulk_export.call_args[0][0]
297
+ assert len(exports_config) == 2
298
+
299
+ def test_format_investigation_as_markdown(self, mock_investigation):
300
+ """Test investigation markdown formatting."""
301
+ markdown = _format_investigation_as_markdown(mock_investigation)
302
+
303
+ # Verify content
304
+ assert '# Investigação INV-001' in markdown
305
+ assert '**Tipo**: contract_analysis' in markdown
306
+ assert '**Status**: completed' in markdown
307
+ assert '## Resumo' in markdown
308
+ assert 'Investigation completed successfully' in markdown
309
+ assert '## Anomalias Detectadas' in markdown
310
+ assert 'Total de anomalias: 1' in markdown
311
+ assert '### Anomalia 1' in markdown
312
+ assert '**Severidade**: 0.85' in markdown
313
+
314
+ def test_format_investigation_as_markdown_no_anomalies(self):
315
+ """Test investigation markdown formatting without anomalies."""
316
+ investigation = {
317
+ 'id': 'INV-002',
318
+ 'type': 'routine_check',
319
+ 'status': 'completed',
320
+ 'created_at': '2024-01-21T10:00:00',
321
+ 'anomalies': []
322
+ }
323
+
324
+ markdown = _format_investigation_as_markdown(investigation)
325
+
326
+ # Should not have anomalies section
327
+ assert '## Anomalias Detectadas' not in markdown
328
+ assert '# Investigação INV-002' in markdown
329
+
330
+ @pytest.mark.asyncio
331
+ @patch('src.api.routes.export.data_service')
332
+ async def test_export_contracts_not_found(
333
+ self, mock_data_service, mock_current_user
334
+ ):
335
+ """Test export contracts when none found."""
336
+ from src.api.routes.export import ExportRequest
337
+
338
+ # Setup mock
339
+ mock_data_service.search_contracts.return_value = []
340
+
341
+ # Create request
342
+ request = ExportRequest(
343
+ export_type='contracts',
344
+ format='excel',
345
+ filters={'year': 2025}
346
+ )
347
+
348
+ # Call function and expect exception
349
+ with pytest.raises(HTTPException) as exc_info:
350
+ await export_contracts(request, mock_current_user)
351
+
352
+ assert exc_info.value.status_code == 404
353
+ assert exc_info.value.detail == "No contracts found with given filters"
354
+
355
+ @pytest.mark.asyncio
356
+ @patch('src.api.routes.export.investigation_service')
357
+ async def test_export_anomalies_none_found(
358
+ self, mock_investigation_service, mock_current_user
359
+ ):
360
+ """Test export anomalies when none found."""
361
+ from src.api.routes.export import ExportRequest
362
+
363
+ # Setup mock - investigations without anomalies
364
+ mock_investigations = [
365
+ {'id': 'INV-001', 'anomalies': []},
366
+ {'id': 'INV-002', 'anomalies': []}
367
+ ]
368
+ mock_investigation_service.list_investigations.return_value = mock_investigations
369
+
370
+ # Create request
371
+ request = ExportRequest(
372
+ export_type='anomalies',
373
+ format='excel'
374
+ )
375
+
376
+ # Call function and expect exception
377
+ with pytest.raises(HTTPException) as exc_info:
378
+ await export_anomalies(request, mock_current_user)
379
+
380
+ assert exc_info.value.status_code == 404
381
+ assert exc_info.value.detail == "No anomalies found"
382
+
383
+ def test_export_request_validation(self):
384
+ """Test ExportRequest validation."""
385
+ from src.api.routes.export import ExportRequest
386
+
387
+ # Valid request
388
+ request = ExportRequest(
389
+ export_type='investigations',
390
+ format='excel'
391
+ )
392
+ assert request.export_type == 'investigations'
393
+ assert request.format == 'excel'
394
+ assert request.include_metadata is True
395
+ assert request.compress is False
396
+
397
+ # Invalid export type
398
+ with pytest.raises(ValueError) as exc_info:
399
+ ExportRequest(
400
+ export_type='invalid_type',
401
+ format='excel'
402
+ )
403
+ assert 'Export type must be one of' in str(exc_info.value)
404
+
405
+ # Invalid format
406
+ with pytest.raises(ValueError) as exc_info:
407
+ ExportRequest(
408
+ export_type='contracts',
409
+ format='invalid_format'
410
+ )
411
+ assert 'Format must be one of' in str(exc_info.value)
412
+
413
+ def test_bulk_export_request_validation(self):
414
+ """Test BulkExportRequest validation."""
415
+ from src.api.routes.export import BulkExportRequest
416
+
417
+ # Valid request
418
+ request = BulkExportRequest(
419
+ exports=[{'type': 'investigation', 'id': '123'}]
420
+ )
421
+ assert len(request.exports) == 1
422
+ assert request.compress is True
423
+
424
+ # Empty exports
425
+ with pytest.raises(ValueError) as exc_info:
426
+ BulkExportRequest(exports=[])
427
+ assert 'At least one export must be specified' in str(exc_info.value)
428
+
429
+ # Too many exports
430
+ with pytest.raises(ValueError) as exc_info:
431
+ BulkExportRequest(
432
+ exports=[{'type': 'investigation', 'id': str(i)} for i in range(51)]
433
+ )
434
+ assert 'Maximum 50 exports allowed' in str(exc_info.value)
tests/unit/services/test_export_service.py ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: tests.unit.services.test_export_service
3
+ Description: Unit tests for export service
4
+ Author: Anderson H. Silva
5
+ Date: 2025-01-25
6
+ License: Proprietary - All rights reserved
7
+ """
8
+
9
+ import pytest
10
+ import base64
11
+ from datetime import datetime
12
+ from unittest.mock import AsyncMock, MagicMock, patch
13
+ import pandas as pd
14
+ from io import BytesIO
15
+
16
+ from src.services.export_service import ExportService, export_service
17
+
18
+
19
+ class TestExportService:
20
+ """Test suite for ExportService."""
21
+
22
+ @pytest.fixture
23
+ def service(self):
24
+ """Create export service instance."""
25
+ return ExportService()
26
+
27
+ @pytest.fixture
28
+ def sample_markdown_content(self):
29
+ """Sample markdown content for testing."""
30
+ return """
31
+ # Relatório de Investigação
32
+
33
+ ## Resumo Executivo
34
+
35
+ Este relatório apresenta os resultados da investigação realizada.
36
+
37
+ ## Anomalias Detectadas
38
+
39
+ ### Anomalia 1
40
+ - **Tipo**: Valor atípico
41
+ - **Severidade**: 0.85
42
+ - **Descrição**: Contrato com valor 300% acima da média
43
+
44
+ ### Anomalia 2
45
+ - **Tipo**: Padrão temporal
46
+ - **Severidade**: 0.72
47
+ - **Descrição**: Concentração anormal de contratos em período específico
48
+
49
+ ## Conclusões
50
+
51
+ As anomalias detectadas indicam possíveis irregularidades que requerem investigação adicional.
52
+
53
+ | Métrica | Valor |
54
+ |---------|--------|
55
+ | Total de contratos | 150 |
56
+ | Valor total | R$ 1.500.000 |
57
+ | Anomalias detectadas | 12 |
58
+ """
59
+
60
+ @pytest.fixture
61
+ def sample_dataframe(self):
62
+ """Sample DataFrame for testing."""
63
+ return pd.DataFrame({
64
+ 'contract_id': ['C001', 'C002', 'C003'],
65
+ 'value': [100000, 250000, 180000],
66
+ 'date': ['2024-01-15', '2024-02-20', '2024-03-10'],
67
+ 'supplier': ['Empresa A', 'Empresa B', 'Empresa C'],
68
+ 'status': ['active', 'completed', 'active']
69
+ })
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_generate_pdf_basic(self, service, sample_markdown_content):
73
+ """Test basic PDF generation."""
74
+ # Generate PDF
75
+ pdf_bytes = await service.generate_pdf(
76
+ content=sample_markdown_content,
77
+ title="Test Report",
78
+ metadata={'author': 'Test'},
79
+ format_type="report"
80
+ )
81
+
82
+ # Verify PDF generated
83
+ assert isinstance(pdf_bytes, bytes)
84
+ assert len(pdf_bytes) > 1000 # Reasonable PDF size
85
+ assert pdf_bytes.startswith(b'%PDF') # PDF header
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_generate_pdf_with_metadata(self, service, sample_markdown_content):
89
+ """Test PDF generation with metadata."""
90
+ metadata = {
91
+ 'generated_at': datetime.now().isoformat(),
92
+ 'report_type': 'investigation',
93
+ 'author': 'Cidadão.AI System'
94
+ }
95
+
96
+ pdf_bytes = await service.generate_pdf(
97
+ content=sample_markdown_content,
98
+ title="Investigation Report",
99
+ metadata=metadata,
100
+ format_type="investigation"
101
+ )
102
+
103
+ assert isinstance(pdf_bytes, bytes)
104
+ assert pdf_bytes.startswith(b'%PDF')
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_generate_excel_single_sheet(self, service, sample_dataframe):
108
+ """Test Excel generation with single sheet."""
109
+ excel_bytes = await service.generate_excel(
110
+ data=sample_dataframe,
111
+ title="Contracts Report",
112
+ metadata={'exported_at': datetime.now().isoformat()}
113
+ )
114
+
115
+ # Verify Excel generated
116
+ assert isinstance(excel_bytes, bytes)
117
+ assert len(excel_bytes) > 500 # Reasonable Excel size
118
+
119
+ # Verify can be read as Excel
120
+ df_read = pd.read_excel(BytesIO(excel_bytes), sheet_name='Dados')
121
+ assert len(df_read) == 3
122
+ assert 'contract_id' in df_read.columns
123
+
124
+ @pytest.mark.asyncio
125
+ async def test_generate_excel_multiple_sheets(self, service, sample_dataframe):
126
+ """Test Excel generation with multiple sheets."""
127
+ data = {
128
+ 'Contratos': sample_dataframe,
129
+ 'Resumo': pd.DataFrame({
130
+ 'Métrica': ['Total Contratos', 'Valor Total', 'Média'],
131
+ 'Valor': [3, 530000, 176666.67]
132
+ })
133
+ }
134
+
135
+ excel_bytes = await service.generate_excel(
136
+ data=data,
137
+ title="Complete Report",
138
+ metadata={'version': '1.0'}
139
+ )
140
+
141
+ assert isinstance(excel_bytes, bytes)
142
+
143
+ # Verify sheets exist
144
+ excel_file = pd.ExcelFile(BytesIO(excel_bytes))
145
+ assert 'Contratos' in excel_file.sheet_names
146
+ assert 'Resumo' in excel_file.sheet_names
147
+ assert 'Metadados' in excel_file.sheet_names
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_generate_csv(self, service, sample_dataframe):
151
+ """Test CSV generation."""
152
+ csv_bytes = await service.generate_csv(sample_dataframe)
153
+
154
+ assert isinstance(csv_bytes, bytes)
155
+ csv_str = csv_bytes.decode('utf-8')
156
+
157
+ # Verify CSV content
158
+ assert 'contract_id,value,date,supplier,status' in csv_str
159
+ assert 'C001,100000' in csv_str
160
+ assert 'Empresa A' in csv_str
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_generate_bulk_export(self, service, sample_markdown_content, sample_dataframe):
164
+ """Test bulk export with ZIP."""
165
+ exports = [
166
+ {
167
+ 'filename': 'report1.pdf',
168
+ 'content': sample_markdown_content,
169
+ 'format': 'pdf',
170
+ 'title': 'Report 1',
171
+ 'metadata': {}
172
+ },
173
+ {
174
+ 'filename': 'data.csv',
175
+ 'data': sample_dataframe,
176
+ 'format': 'csv'
177
+ },
178
+ {
179
+ 'filename': 'summary.txt',
180
+ 'content': 'Summary of investigations',
181
+ 'format': 'txt'
182
+ }
183
+ ]
184
+
185
+ zip_bytes = await service.generate_bulk_export(exports)
186
+
187
+ assert isinstance(zip_bytes, bytes)
188
+ assert len(zip_bytes) > 1000 # Reasonable ZIP size
189
+
190
+ # Verify ZIP content
191
+ import zipfile
192
+ with zipfile.ZipFile(BytesIO(zip_bytes), 'r') as zf:
193
+ assert 'report1.pdf' in zf.namelist()
194
+ assert 'data.csv' in zf.namelist()
195
+ assert 'summary.txt' in zf.namelist()
196
+
197
+ @pytest.mark.asyncio
198
+ async def test_convert_investigation_to_excel(self, service):
199
+ """Test investigation data to Excel conversion."""
200
+ investigation_data = {
201
+ 'id': 'INV-001',
202
+ 'type': 'contract_analysis',
203
+ 'status': 'completed',
204
+ 'created_at': '2024-01-20T10:00:00',
205
+ 'completed_at': '2024-01-20T10:30:00',
206
+ 'duration_minutes': 30,
207
+ 'anomalies': [
208
+ {
209
+ 'type': 'value_outlier',
210
+ 'severity': 0.85,
211
+ 'description': 'High value detected',
212
+ 'contract_id': 'C001'
213
+ },
214
+ {
215
+ 'type': 'temporal_pattern',
216
+ 'severity': 0.72,
217
+ 'description': 'Unusual timing',
218
+ 'contract_id': 'C002'
219
+ }
220
+ ],
221
+ 'contracts': [
222
+ {
223
+ 'id': 'C001',
224
+ 'value': 500000,
225
+ 'supplier': 'Company A'
226
+ },
227
+ {
228
+ 'id': 'C002',
229
+ 'value': 300000,
230
+ 'supplier': 'Company B'
231
+ }
232
+ ],
233
+ 'results': {
234
+ 'total_analyzed': 100,
235
+ 'anomalies_found': 2,
236
+ 'risk_score': 0.78
237
+ }
238
+ }
239
+
240
+ excel_bytes = await service.convert_investigation_to_excel(investigation_data)
241
+
242
+ assert isinstance(excel_bytes, bytes)
243
+
244
+ # Verify sheets
245
+ excel_file = pd.ExcelFile(BytesIO(excel_bytes))
246
+ assert 'Resumo' in excel_file.sheet_names
247
+ assert 'Anomalias' in excel_file.sheet_names
248
+ assert 'Contratos' in excel_file.sheet_names
249
+ assert 'Resultados' in excel_file.sheet_names
250
+
251
+ @pytest.mark.asyncio
252
+ async def test_pdf_generation_thread_safety(self, service, sample_markdown_content):
253
+ """Test PDF generation is thread-safe."""
254
+ # Generate multiple PDFs concurrently
255
+ import asyncio
256
+
257
+ tasks = []
258
+ for i in range(5):
259
+ task = service.generate_pdf(
260
+ content=sample_markdown_content + f"\n\n## Report {i}",
261
+ title=f"Report {i}",
262
+ metadata={'report_id': i}
263
+ )
264
+ tasks.append(task)
265
+
266
+ results = await asyncio.gather(*tasks)
267
+
268
+ # All PDFs should be generated successfully
269
+ assert len(results) == 5
270
+ for pdf_bytes in results:
271
+ assert isinstance(pdf_bytes, bytes)
272
+ assert pdf_bytes.startswith(b'%PDF')
273
+
274
+ def test_custom_styles_creation(self, service):
275
+ """Test custom PDF styles are created."""
276
+ assert 'CustomTitle' in service.styles
277
+ assert 'CustomSubtitle' in service.styles
278
+ assert 'CustomBody' in service.styles
279
+ assert 'CustomFooter' in service.styles
280
+
281
+ @pytest.mark.asyncio
282
+ async def test_excel_formatting(self, service, sample_dataframe):
283
+ """Test Excel formatting is applied."""
284
+ excel_bytes = await service.generate_excel(
285
+ data=sample_dataframe,
286
+ title="Formatted Report",
287
+ metadata={'test': 'formatting'}
288
+ )
289
+
290
+ # Load and check formatting
291
+ from openpyxl import load_workbook
292
+ wb = load_workbook(BytesIO(excel_bytes))
293
+ ws = wb['Dados']
294
+
295
+ # Check title is merged
296
+ assert ws.merged_cells
297
+
298
+ # Check title cell has custom font
299
+ title_cell = ws['A1']
300
+ assert title_cell.font.bold
301
+ assert title_cell.font.size == 16
302
+
303
+ @pytest.mark.asyncio
304
+ async def test_empty_dataframe_handling(self, service):
305
+ """Test handling of empty DataFrames."""
306
+ empty_df = pd.DataFrame()
307
+
308
+ csv_bytes = await service.generate_csv(empty_df)
309
+ assert csv_bytes == b''
310
+
311
+ # Excel should still generate with headers
312
+ excel_bytes = await service.generate_excel(
313
+ data=empty_df,
314
+ title="Empty Report"
315
+ )
316
+ assert isinstance(excel_bytes, bytes)
317
+
318
+ @pytest.mark.asyncio
319
+ async def test_large_content_handling(self, service):
320
+ """Test handling of large content."""
321
+ # Generate large markdown content
322
+ large_content = "# Large Report\n\n"
323
+ for i in range(100):
324
+ large_content += f"## Section {i}\n"
325
+ large_content += "This is a paragraph with some content. " * 50
326
+ large_content += "\n\n"
327
+
328
+ pdf_bytes = await service.generate_pdf(
329
+ content=large_content,
330
+ title="Large Report",
331
+ format_type="report"
332
+ )
333
+
334
+ assert isinstance(pdf_bytes, bytes)
335
+ assert len(pdf_bytes) > 10000 # Should be a sizeable PDF
336
+
337
+ @pytest.mark.asyncio
338
+ async def test_special_characters_handling(self, service):
339
+ """Test handling of special characters."""
340
+ content_with_special = """
341
+ # Relatório com Caracteres Especiais
342
+
343
+ ## Seção com acentuação: áéíóú àèìòù ãõ ç
344
+
345
+ ### Símbolos: @#$%^&*()_+-={}[]|:";'<>?,./
346
+
347
+ **Texto em negrito** e *texto em itálico*
348
+
349
+ Código: `print("Olá, Mundo!")`
350
+ """
351
+
352
+ pdf_bytes = await service.generate_pdf(
353
+ content=content_with_special,
354
+ title="Relatório Especial"
355
+ )
356
+
357
+ assert isinstance(pdf_bytes, bytes)
358
+ assert pdf_bytes.startswith(b'%PDF')
359
+
360
+ def test_global_service_instance(self):
361
+ """Test global service instance is available."""
362
+ assert export_service is not None
363
+ assert isinstance(export_service, ExportService)