truthound-dashboard 1.3.1__py3-none-any.whl → 1.4.1__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.
- truthound_dashboard/api/alerts.py +258 -0
- truthound_dashboard/api/anomaly.py +1302 -0
- truthound_dashboard/api/cross_alerts.py +352 -0
- truthound_dashboard/api/deps.py +143 -0
- truthound_dashboard/api/drift_monitor.py +540 -0
- truthound_dashboard/api/lineage.py +1151 -0
- truthound_dashboard/api/maintenance.py +363 -0
- truthound_dashboard/api/middleware.py +373 -1
- truthound_dashboard/api/model_monitoring.py +805 -0
- truthound_dashboard/api/notifications_advanced.py +2452 -0
- truthound_dashboard/api/plugins.py +2096 -0
- truthound_dashboard/api/profile.py +211 -14
- truthound_dashboard/api/reports.py +853 -0
- truthound_dashboard/api/router.py +147 -0
- truthound_dashboard/api/rule_suggestions.py +310 -0
- truthound_dashboard/api/schema_evolution.py +231 -0
- truthound_dashboard/api/sources.py +47 -3
- truthound_dashboard/api/triggers.py +190 -0
- truthound_dashboard/api/validations.py +13 -0
- truthound_dashboard/api/validators.py +333 -4
- truthound_dashboard/api/versioning.py +309 -0
- truthound_dashboard/api/websocket.py +301 -0
- truthound_dashboard/core/__init__.py +27 -0
- truthound_dashboard/core/anomaly.py +1395 -0
- truthound_dashboard/core/anomaly_explainer.py +633 -0
- truthound_dashboard/core/cache.py +206 -0
- truthound_dashboard/core/cached_services.py +422 -0
- truthound_dashboard/core/charts.py +352 -0
- truthound_dashboard/core/connections.py +1069 -42
- truthound_dashboard/core/cross_alerts.py +837 -0
- truthound_dashboard/core/drift_monitor.py +1477 -0
- truthound_dashboard/core/drift_sampling.py +669 -0
- truthound_dashboard/core/i18n/__init__.py +42 -0
- truthound_dashboard/core/i18n/detector.py +173 -0
- truthound_dashboard/core/i18n/messages.py +564 -0
- truthound_dashboard/core/lineage.py +971 -0
- truthound_dashboard/core/maintenance.py +443 -5
- truthound_dashboard/core/model_monitoring.py +1043 -0
- truthound_dashboard/core/notifications/channels.py +1020 -1
- truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
- truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
- truthound_dashboard/core/notifications/deduplication/service.py +400 -0
- truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
- truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
- truthound_dashboard/core/notifications/dispatcher.py +43 -0
- truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
- truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
- truthound_dashboard/core/notifications/escalation/engine.py +429 -0
- truthound_dashboard/core/notifications/escalation/models.py +336 -0
- truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
- truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
- truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
- truthound_dashboard/core/notifications/events.py +49 -0
- truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
- truthound_dashboard/core/notifications/metrics/base.py +528 -0
- truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
- truthound_dashboard/core/notifications/routing/__init__.py +169 -0
- truthound_dashboard/core/notifications/routing/combinators.py +184 -0
- truthound_dashboard/core/notifications/routing/config.py +375 -0
- truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
- truthound_dashboard/core/notifications/routing/engine.py +382 -0
- truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
- truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
- truthound_dashboard/core/notifications/routing/rules.py +625 -0
- truthound_dashboard/core/notifications/routing/validator.py +678 -0
- truthound_dashboard/core/notifications/service.py +2 -0
- truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
- truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
- truthound_dashboard/core/notifications/throttling/builder.py +311 -0
- truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
- truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
- truthound_dashboard/core/openlineage.py +1028 -0
- truthound_dashboard/core/plugins/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/__init__.py +39 -0
- truthound_dashboard/core/plugins/docs/extractor.py +703 -0
- truthound_dashboard/core/plugins/docs/renderers.py +804 -0
- truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
- truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
- truthound_dashboard/core/plugins/hooks/manager.py +403 -0
- truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
- truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
- truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
- truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
- truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
- truthound_dashboard/core/plugins/loader.py +504 -0
- truthound_dashboard/core/plugins/registry.py +810 -0
- truthound_dashboard/core/plugins/reporter_executor.py +588 -0
- truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
- truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
- truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
- truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
- truthound_dashboard/core/plugins/sandbox.py +617 -0
- truthound_dashboard/core/plugins/security/__init__.py +68 -0
- truthound_dashboard/core/plugins/security/analyzer.py +535 -0
- truthound_dashboard/core/plugins/security/policies.py +311 -0
- truthound_dashboard/core/plugins/security/protocols.py +296 -0
- truthound_dashboard/core/plugins/security/signing.py +842 -0
- truthound_dashboard/core/plugins/security.py +446 -0
- truthound_dashboard/core/plugins/validator_executor.py +401 -0
- truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
- truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
- truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
- truthound_dashboard/core/plugins/versioning/semver.py +266 -0
- truthound_dashboard/core/profile_comparison.py +601 -0
- truthound_dashboard/core/report_history.py +570 -0
- truthound_dashboard/core/reporters/__init__.py +57 -0
- truthound_dashboard/core/reporters/base.py +296 -0
- truthound_dashboard/core/reporters/csv_reporter.py +155 -0
- truthound_dashboard/core/reporters/html_reporter.py +598 -0
- truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
- truthound_dashboard/core/reporters/i18n/base.py +494 -0
- truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
- truthound_dashboard/core/reporters/json_reporter.py +160 -0
- truthound_dashboard/core/reporters/junit_reporter.py +233 -0
- truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
- truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
- truthound_dashboard/core/reporters/registry.py +272 -0
- truthound_dashboard/core/rule_generator.py +2088 -0
- truthound_dashboard/core/scheduler.py +822 -12
- truthound_dashboard/core/schema_evolution.py +858 -0
- truthound_dashboard/core/services.py +152 -9
- truthound_dashboard/core/statistics.py +718 -0
- truthound_dashboard/core/streaming_anomaly.py +883 -0
- truthound_dashboard/core/triggers/__init__.py +45 -0
- truthound_dashboard/core/triggers/base.py +226 -0
- truthound_dashboard/core/triggers/evaluators.py +609 -0
- truthound_dashboard/core/triggers/factory.py +363 -0
- truthound_dashboard/core/unified_alerts.py +870 -0
- truthound_dashboard/core/validation_limits.py +509 -0
- truthound_dashboard/core/versioning.py +709 -0
- truthound_dashboard/core/websocket/__init__.py +59 -0
- truthound_dashboard/core/websocket/manager.py +512 -0
- truthound_dashboard/core/websocket/messages.py +130 -0
- truthound_dashboard/db/__init__.py +30 -0
- truthound_dashboard/db/models.py +3375 -3
- truthound_dashboard/main.py +22 -0
- truthound_dashboard/schemas/__init__.py +396 -1
- truthound_dashboard/schemas/anomaly.py +1258 -0
- truthound_dashboard/schemas/base.py +4 -0
- truthound_dashboard/schemas/cross_alerts.py +334 -0
- truthound_dashboard/schemas/drift_monitor.py +890 -0
- truthound_dashboard/schemas/lineage.py +428 -0
- truthound_dashboard/schemas/maintenance.py +154 -0
- truthound_dashboard/schemas/model_monitoring.py +374 -0
- truthound_dashboard/schemas/notifications_advanced.py +1363 -0
- truthound_dashboard/schemas/openlineage.py +704 -0
- truthound_dashboard/schemas/plugins.py +1293 -0
- truthound_dashboard/schemas/profile.py +420 -34
- truthound_dashboard/schemas/profile_comparison.py +242 -0
- truthound_dashboard/schemas/reports.py +285 -0
- truthound_dashboard/schemas/rule_suggestion.py +434 -0
- truthound_dashboard/schemas/schema_evolution.py +164 -0
- truthound_dashboard/schemas/source.py +117 -2
- truthound_dashboard/schemas/triggers.py +511 -0
- truthound_dashboard/schemas/unified_alerts.py +223 -0
- truthound_dashboard/schemas/validation.py +25 -1
- truthound_dashboard/schemas/validators/__init__.py +11 -0
- truthound_dashboard/schemas/validators/base.py +151 -0
- truthound_dashboard/schemas/versioning.py +152 -0
- truthound_dashboard/static/index.html +2 -2
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/METADATA +147 -23
- truthound_dashboard-1.4.1.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
- truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
- truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.1.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
|
+
]
|