truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,570 @@
1
+ """Report History Service.
2
+
3
+ This module provides services for managing generated report history,
4
+ including CRUD operations, statistics, and cleanup.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Any, Literal
14
+
15
+ from sqlalchemy import and_, desc, func, or_, select
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+ from sqlalchemy.orm import selectinload
18
+
19
+ from truthound_dashboard.db.models import (
20
+ CustomReporter,
21
+ GeneratedReport,
22
+ ReportFormatType,
23
+ ReportStatus,
24
+ Source,
25
+ Validation,
26
+ )
27
+
28
+
29
+ class ReportHistoryService:
30
+ """Service for managing generated report history.
31
+
32
+ Provides CRUD operations for report history records,
33
+ statistics aggregation, and cleanup functionality.
34
+ """
35
+
36
+ def __init__(self, session: AsyncSession) -> None:
37
+ """Initialize service with database session.
38
+
39
+ Args:
40
+ session: Async database session.
41
+ """
42
+ self.session = session
43
+ self._reports_dir = Path("data/reports")
44
+
45
+ async def list_reports(
46
+ self,
47
+ source_id: str | None = None,
48
+ validation_id: str | None = None,
49
+ reporter_id: str | None = None,
50
+ format: str | None = None,
51
+ status: str | None = None,
52
+ include_expired: bool = False,
53
+ search: str | None = None,
54
+ sort_by: str = "created_at",
55
+ sort_order: Literal["asc", "desc"] = "desc",
56
+ page: int = 1,
57
+ page_size: int = 20,
58
+ ) -> tuple[list[GeneratedReport], int]:
59
+ """List generated reports with filtering and pagination.
60
+
61
+ Args:
62
+ source_id: Filter by source ID.
63
+ validation_id: Filter by validation ID.
64
+ reporter_id: Filter by reporter ID.
65
+ format: Filter by format.
66
+ status: Filter by status.
67
+ include_expired: Include expired reports.
68
+ search: Search by name.
69
+ sort_by: Field to sort by.
70
+ sort_order: Sort direction.
71
+ page: Page number (1-based).
72
+ page_size: Items per page.
73
+
74
+ Returns:
75
+ Tuple of (reports list, total count).
76
+ """
77
+ # Build base query with relationships
78
+ query = select(GeneratedReport).options(
79
+ selectinload(GeneratedReport.source),
80
+ selectinload(GeneratedReport.validation),
81
+ selectinload(GeneratedReport.reporter),
82
+ )
83
+
84
+ # Apply filters
85
+ conditions = []
86
+
87
+ if source_id:
88
+ conditions.append(GeneratedReport.source_id == source_id)
89
+
90
+ if validation_id:
91
+ conditions.append(GeneratedReport.validation_id == validation_id)
92
+
93
+ if reporter_id:
94
+ conditions.append(GeneratedReport.reporter_id == reporter_id)
95
+
96
+ if format:
97
+ try:
98
+ format_enum = ReportFormatType(format.lower())
99
+ conditions.append(GeneratedReport.format == format_enum)
100
+ except ValueError:
101
+ pass
102
+
103
+ if status:
104
+ try:
105
+ status_enum = ReportStatus(status.lower())
106
+ conditions.append(GeneratedReport.status == status_enum)
107
+ except ValueError:
108
+ pass
109
+
110
+ if not include_expired:
111
+ conditions.append(
112
+ or_(
113
+ GeneratedReport.expires_at.is_(None),
114
+ GeneratedReport.expires_at > datetime.utcnow(),
115
+ )
116
+ )
117
+
118
+ if search:
119
+ conditions.append(GeneratedReport.name.ilike(f"%{search}%"))
120
+
121
+ if conditions:
122
+ query = query.where(and_(*conditions))
123
+
124
+ # Get total count
125
+ count_query = select(func.count()).select_from(
126
+ query.subquery()
127
+ )
128
+ total = await self.session.scalar(count_query) or 0
129
+
130
+ # Apply sorting
131
+ sort_column = getattr(GeneratedReport, sort_by, GeneratedReport.created_at)
132
+ if sort_order == "desc":
133
+ query = query.order_by(desc(sort_column))
134
+ else:
135
+ query = query.order_by(sort_column)
136
+
137
+ # Apply pagination
138
+ offset = (page - 1) * page_size
139
+ query = query.offset(offset).limit(page_size)
140
+
141
+ result = await self.session.execute(query)
142
+ reports = list(result.scalars().all())
143
+
144
+ return reports, total
145
+
146
+ async def get_report(self, report_id: str) -> GeneratedReport | None:
147
+ """Get a single report by ID.
148
+
149
+ Args:
150
+ report_id: Report ID.
151
+
152
+ Returns:
153
+ GeneratedReport or None if not found.
154
+ """
155
+ query = (
156
+ select(GeneratedReport)
157
+ .options(
158
+ selectinload(GeneratedReport.source),
159
+ selectinload(GeneratedReport.validation),
160
+ selectinload(GeneratedReport.reporter),
161
+ )
162
+ .where(GeneratedReport.id == report_id)
163
+ )
164
+ result = await self.session.execute(query)
165
+ return result.scalar_one_or_none()
166
+
167
+ async def create_report(
168
+ self,
169
+ name: str,
170
+ format: str,
171
+ validation_id: str | None = None,
172
+ source_id: str | None = None,
173
+ reporter_id: str | None = None,
174
+ description: str | None = None,
175
+ theme: str | None = None,
176
+ locale: str = "en",
177
+ config: dict[str, Any] | None = None,
178
+ metadata: dict[str, Any] | None = None,
179
+ expires_in_days: int | None = 30,
180
+ ) -> GeneratedReport:
181
+ """Create a new report record.
182
+
183
+ Args:
184
+ name: Report name.
185
+ format: Report format.
186
+ validation_id: Associated validation ID.
187
+ source_id: Associated source ID.
188
+ reporter_id: Custom reporter ID.
189
+ description: Report description.
190
+ theme: Visual theme.
191
+ locale: Language locale.
192
+ config: Generation config.
193
+ metadata: Additional metadata.
194
+ expires_in_days: Days until expiration.
195
+
196
+ Returns:
197
+ Created GeneratedReport.
198
+ """
199
+ # Calculate expiration
200
+ expires_at = None
201
+ if expires_in_days:
202
+ expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
203
+
204
+ # Parse format
205
+ try:
206
+ format_enum = ReportFormatType(format.lower())
207
+ except ValueError:
208
+ format_enum = ReportFormatType.HTML
209
+
210
+ report = GeneratedReport(
211
+ name=name,
212
+ description=description,
213
+ format=format_enum,
214
+ theme=theme,
215
+ locale=locale,
216
+ status=ReportStatus.PENDING,
217
+ config=config,
218
+ metadata=metadata,
219
+ validation_id=validation_id,
220
+ source_id=source_id,
221
+ reporter_id=reporter_id,
222
+ expires_at=expires_at,
223
+ )
224
+
225
+ self.session.add(report)
226
+ await self.session.commit()
227
+ await self.session.refresh(report)
228
+
229
+ return report
230
+
231
+ async def update_report(
232
+ self,
233
+ report_id: str,
234
+ name: str | None = None,
235
+ description: str | None = None,
236
+ metadata: dict[str, Any] | None = None,
237
+ ) -> GeneratedReport | None:
238
+ """Update a report record.
239
+
240
+ Args:
241
+ report_id: Report ID.
242
+ name: New name.
243
+ description: New description.
244
+ metadata: New metadata.
245
+
246
+ Returns:
247
+ Updated GeneratedReport or None if not found.
248
+ """
249
+ report = await self.get_report(report_id)
250
+ if not report:
251
+ return None
252
+
253
+ if name is not None:
254
+ report.name = name
255
+ if description is not None:
256
+ report.description = description
257
+ if metadata is not None:
258
+ report.metadata = metadata
259
+
260
+ await self.session.commit()
261
+ await self.session.refresh(report)
262
+ return report
263
+
264
+ async def delete_report(self, report_id: str) -> bool:
265
+ """Delete a report record and its file.
266
+
267
+ Args:
268
+ report_id: Report ID.
269
+
270
+ Returns:
271
+ True if deleted, False if not found.
272
+ """
273
+ report = await self.get_report(report_id)
274
+ if not report:
275
+ return False
276
+
277
+ # Delete file if exists
278
+ if report.file_path and os.path.exists(report.file_path):
279
+ try:
280
+ os.remove(report.file_path)
281
+ except OSError:
282
+ pass
283
+
284
+ await self.session.delete(report)
285
+ await self.session.commit()
286
+ return True
287
+
288
+ async def mark_generating(self, report_id: str) -> GeneratedReport | None:
289
+ """Mark report as generating.
290
+
291
+ Args:
292
+ report_id: Report ID.
293
+
294
+ Returns:
295
+ Updated report or None.
296
+ """
297
+ report = await self.get_report(report_id)
298
+ if not report:
299
+ return None
300
+
301
+ report.status = ReportStatus.GENERATING
302
+ await self.session.commit()
303
+ await self.session.refresh(report)
304
+ return report
305
+
306
+ async def mark_completed(
307
+ self,
308
+ report_id: str,
309
+ content: bytes | str,
310
+ generation_time_ms: float,
311
+ ) -> GeneratedReport | None:
312
+ """Mark report as completed and store content.
313
+
314
+ Args:
315
+ report_id: Report ID.
316
+ content: Report content.
317
+ generation_time_ms: Generation time in milliseconds.
318
+
319
+ Returns:
320
+ Updated report or None.
321
+ """
322
+ report = await self.get_report(report_id)
323
+ if not report:
324
+ return None
325
+
326
+ # Convert string to bytes if needed
327
+ if isinstance(content, str):
328
+ content = content.encode("utf-8")
329
+
330
+ # Calculate hash
331
+ content_hash = hashlib.sha256(content).hexdigest()
332
+
333
+ # Store file
334
+ self._reports_dir.mkdir(parents=True, exist_ok=True)
335
+
336
+ # Determine extension
337
+ ext_map = {
338
+ ReportFormatType.HTML: ".html",
339
+ ReportFormatType.PDF: ".pdf",
340
+ ReportFormatType.CSV: ".csv",
341
+ ReportFormatType.JSON: ".json",
342
+ ReportFormatType.MARKDOWN: ".md",
343
+ ReportFormatType.JUNIT: ".xml",
344
+ ReportFormatType.EXCEL: ".xlsx",
345
+ ReportFormatType.CUSTOM: ".txt",
346
+ }
347
+ ext = ext_map.get(report.format, ".html")
348
+ file_name = f"{report.id}{ext}"
349
+ file_path = self._reports_dir / file_name
350
+
351
+ with open(file_path, "wb") as f:
352
+ f.write(content)
353
+
354
+ # Update report
355
+ report.mark_completed(
356
+ file_path=str(file_path),
357
+ file_size=len(content),
358
+ generation_time_ms=generation_time_ms,
359
+ )
360
+ report.content_hash = content_hash
361
+
362
+ await self.session.commit()
363
+ await self.session.refresh(report)
364
+ return report
365
+
366
+ async def mark_failed(
367
+ self,
368
+ report_id: str,
369
+ error_message: str,
370
+ ) -> GeneratedReport | None:
371
+ """Mark report as failed.
372
+
373
+ Args:
374
+ report_id: Report ID.
375
+ error_message: Error message.
376
+
377
+ Returns:
378
+ Updated report or None.
379
+ """
380
+ report = await self.get_report(report_id)
381
+ if not report:
382
+ return None
383
+
384
+ report.mark_failed(error_message)
385
+ await self.session.commit()
386
+ await self.session.refresh(report)
387
+ return report
388
+
389
+ async def record_download(self, report_id: str) -> GeneratedReport | None:
390
+ """Record a download event.
391
+
392
+ Args:
393
+ report_id: Report ID.
394
+
395
+ Returns:
396
+ Updated report or None.
397
+ """
398
+ report = await self.get_report(report_id)
399
+ if not report:
400
+ return None
401
+
402
+ report.increment_download()
403
+ await self.session.commit()
404
+ await self.session.refresh(report)
405
+ return report
406
+
407
+ async def get_report_content(self, report_id: str) -> tuple[bytes | None, str | None]:
408
+ """Get report file content.
409
+
410
+ Args:
411
+ report_id: Report ID.
412
+
413
+ Returns:
414
+ Tuple of (content, content_type) or (None, None) if not found.
415
+ """
416
+ report = await self.get_report(report_id)
417
+ if not report or not report.file_path:
418
+ return None, None
419
+
420
+ if not os.path.exists(report.file_path):
421
+ return None, None
422
+
423
+ content_type_map = {
424
+ ReportFormatType.HTML: "text/html",
425
+ ReportFormatType.PDF: "application/pdf",
426
+ ReportFormatType.CSV: "text/csv",
427
+ ReportFormatType.JSON: "application/json",
428
+ ReportFormatType.MARKDOWN: "text/markdown",
429
+ ReportFormatType.JUNIT: "application/xml",
430
+ ReportFormatType.EXCEL: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
431
+ ReportFormatType.CUSTOM: "text/plain",
432
+ }
433
+
434
+ with open(report.file_path, "rb") as f:
435
+ content = f.read()
436
+
437
+ content_type = content_type_map.get(report.format, "application/octet-stream")
438
+ return content, content_type
439
+
440
+ async def get_statistics(self) -> dict[str, Any]:
441
+ """Get report statistics.
442
+
443
+ Returns:
444
+ Statistics dictionary.
445
+ """
446
+ # Total reports
447
+ total_query = select(func.count(GeneratedReport.id))
448
+ total = await self.session.scalar(total_query) or 0
449
+
450
+ # Total size
451
+ size_query = select(func.coalesce(func.sum(GeneratedReport.file_size), 0))
452
+ total_size = await self.session.scalar(size_query) or 0
453
+
454
+ # Reports by format
455
+ format_query = select(
456
+ GeneratedReport.format,
457
+ func.count(GeneratedReport.id),
458
+ ).group_by(GeneratedReport.format)
459
+ format_result = await self.session.execute(format_query)
460
+ by_format = {
461
+ row[0].value if hasattr(row[0], "value") else row[0]: row[1]
462
+ for row in format_result
463
+ }
464
+
465
+ # Reports by status
466
+ status_query = select(
467
+ GeneratedReport.status,
468
+ func.count(GeneratedReport.id),
469
+ ).group_by(GeneratedReport.status)
470
+ status_result = await self.session.execute(status_query)
471
+ by_status = {
472
+ row[0].value if hasattr(row[0], "value") else row[0]: row[1]
473
+ for row in status_result
474
+ }
475
+
476
+ # Total downloads
477
+ downloads_query = select(
478
+ func.coalesce(func.sum(GeneratedReport.downloaded_count), 0)
479
+ )
480
+ total_downloads = await self.session.scalar(downloads_query) or 0
481
+
482
+ # Average generation time
483
+ avg_time_query = select(
484
+ func.avg(GeneratedReport.generation_time_ms)
485
+ ).where(GeneratedReport.generation_time_ms.isnot(None))
486
+ avg_time = await self.session.scalar(avg_time_query)
487
+
488
+ # Expired count
489
+ expired_query = select(func.count(GeneratedReport.id)).where(
490
+ and_(
491
+ GeneratedReport.expires_at.isnot(None),
492
+ GeneratedReport.expires_at < datetime.utcnow(),
493
+ )
494
+ )
495
+ expired_count = await self.session.scalar(expired_query) or 0
496
+
497
+ # Unique reporters used
498
+ reporters_query = select(
499
+ func.count(func.distinct(GeneratedReport.reporter_id))
500
+ ).where(GeneratedReport.reporter_id.isnot(None))
501
+ reporters_used = await self.session.scalar(reporters_query) or 0
502
+
503
+ return {
504
+ "total_reports": total,
505
+ "total_size_bytes": int(total_size),
506
+ "reports_by_format": by_format,
507
+ "reports_by_status": by_status,
508
+ "total_downloads": int(total_downloads),
509
+ "avg_generation_time_ms": float(avg_time) if avg_time else None,
510
+ "expired_count": expired_count,
511
+ "reporters_used": reporters_used,
512
+ }
513
+
514
+ async def cleanup_expired(self) -> int:
515
+ """Delete expired reports.
516
+
517
+ Returns:
518
+ Number of reports deleted.
519
+ """
520
+ # Find expired reports
521
+ query = select(GeneratedReport).where(
522
+ and_(
523
+ GeneratedReport.expires_at.isnot(None),
524
+ GeneratedReport.expires_at < datetime.utcnow(),
525
+ )
526
+ )
527
+ result = await self.session.execute(query)
528
+ expired = list(result.scalars().all())
529
+
530
+ count = 0
531
+ for report in expired:
532
+ # Delete file
533
+ if report.file_path and os.path.exists(report.file_path):
534
+ try:
535
+ os.remove(report.file_path)
536
+ except OSError:
537
+ pass
538
+
539
+ await self.session.delete(report)
540
+ count += 1
541
+
542
+ await self.session.commit()
543
+ return count
544
+
545
+ async def find_by_hash(self, content_hash: str) -> GeneratedReport | None:
546
+ """Find report by content hash for deduplication.
547
+
548
+ Args:
549
+ content_hash: Content hash.
550
+
551
+ Returns:
552
+ Existing report with same hash or None.
553
+ """
554
+ query = (
555
+ select(GeneratedReport)
556
+ .where(
557
+ and_(
558
+ GeneratedReport.content_hash == content_hash,
559
+ GeneratedReport.status == ReportStatus.COMPLETED,
560
+ or_(
561
+ GeneratedReport.expires_at.is_(None),
562
+ GeneratedReport.expires_at > datetime.utcnow(),
563
+ ),
564
+ )
565
+ )
566
+ .order_by(desc(GeneratedReport.created_at))
567
+ .limit(1)
568
+ )
569
+ result = await self.session.execute(query)
570
+ return result.scalar_one_or_none()
@@ -0,0 +1,57 @@
1
+ """Report generation system with multiple format support.
2
+
3
+ This module provides an extensible reporter system for generating
4
+ validation reports in various formats (HTML, CSV, Markdown, JSON, PDF).
5
+
6
+ The reporter system uses the Strategy pattern for format flexibility
7
+ and Template Method pattern for consistent report structure.
8
+
9
+ Example:
10
+ from truthound_dashboard.core.reporters import get_reporter, ReportFormat
11
+
12
+ reporter = get_reporter(ReportFormat.HTML)
13
+ report = await reporter.generate(validation)
14
+
15
+ # Or use the convenience function
16
+ from truthound_dashboard.core.reporters import generate_report
17
+ report = await generate_report(validation, format="html")
18
+ """
19
+
20
+ from .base import (
21
+ Reporter,
22
+ ReportFormat,
23
+ ReportMetadata,
24
+ ReportResult,
25
+ ReportTheme,
26
+ )
27
+ from .csv_reporter import CSVReporter
28
+ from .html_reporter import HTMLReporter
29
+ from .json_reporter import JSONReporter
30
+ from .markdown_reporter import MarkdownReporter
31
+ from .registry import (
32
+ ReporterRegistry,
33
+ generate_report,
34
+ get_available_formats,
35
+ get_reporter,
36
+ register_reporter,
37
+ )
38
+
39
+ __all__ = [
40
+ # Base classes
41
+ "Reporter",
42
+ "ReportFormat",
43
+ "ReportMetadata",
44
+ "ReportResult",
45
+ "ReportTheme",
46
+ # Implementations
47
+ "CSVReporter",
48
+ "HTMLReporter",
49
+ "JSONReporter",
50
+ "MarkdownReporter",
51
+ # Registry
52
+ "ReporterRegistry",
53
+ "generate_report",
54
+ "get_available_formats",
55
+ "get_reporter",
56
+ "register_reporter",
57
+ ]