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,853 @@
1
+ """Reports API endpoints.
2
+
3
+ This module provides endpoints for generating and downloading validation reports
4
+ with internationalization (i18n) support for 15 languages.
5
+
6
+ Includes Report History management for tracking generated reports.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Annotated, Any, Literal
13
+
14
+ from fastapi import APIRouter, HTTPException, Path, Query
15
+ from fastapi.responses import Response
16
+ from pydantic import BaseModel
17
+
18
+ from truthound_dashboard.core.reporters import (
19
+ ReportFormat,
20
+ ReportTheme,
21
+ generate_report,
22
+ get_available_formats,
23
+ )
24
+ from truthound_dashboard.core.reporters.registry import get_report_locales
25
+ from truthound_dashboard.core.reporters.i18n import SupportedLocale
26
+ from truthound_dashboard.schemas import (
27
+ AvailableFormatsResponse,
28
+ ReportGenerateRequest,
29
+ ReportMetadataResponse,
30
+ ReportResponse,
31
+ )
32
+ from truthound_dashboard.schemas.reports import (
33
+ BulkReportGenerateRequest,
34
+ BulkReportGenerateResponse,
35
+ GeneratedReportCreate,
36
+ GeneratedReportListResponse,
37
+ GeneratedReportResponse,
38
+ GeneratedReportUpdate,
39
+ ReportHistoryQuery,
40
+ ReportStatistics,
41
+ ReportStatus,
42
+ )
43
+
44
+ from .deps import ReportHistoryServiceDep, ValidationServiceDep
45
+
46
+ router = APIRouter()
47
+
48
+
49
+ class LocaleInfo(BaseModel):
50
+ """Locale information for API responses."""
51
+
52
+ code: str
53
+ english_name: str
54
+ native_name: str
55
+ flag: str
56
+ rtl: bool
57
+
58
+
59
+ class AvailableFormatsWithLocalesResponse(BaseModel):
60
+ """Response with formats, themes, and supported locales."""
61
+
62
+ formats: list[str]
63
+ themes: list[str]
64
+ locales: list[LocaleInfo]
65
+
66
+
67
+ @router.get(
68
+ "/formats",
69
+ response_model=AvailableFormatsWithLocalesResponse,
70
+ summary="Get available report formats, themes, and locales",
71
+ description="List all available report formats, themes, and supported languages",
72
+ )
73
+ async def list_formats() -> AvailableFormatsWithLocalesResponse:
74
+ """Get list of available report formats, themes, and locales.
75
+
76
+ Returns:
77
+ Available formats, themes, and supported locales.
78
+ """
79
+ formats = get_available_formats()
80
+ themes = [t.value for t in ReportTheme]
81
+ locales_raw = get_report_locales()
82
+
83
+ locales = [
84
+ LocaleInfo(
85
+ code=loc["code"],
86
+ english_name=loc["english_name"],
87
+ native_name=loc["native_name"],
88
+ flag=loc["flag"],
89
+ rtl=loc["rtl"],
90
+ )
91
+ for loc in locales_raw
92
+ ]
93
+
94
+ return AvailableFormatsWithLocalesResponse(
95
+ formats=formats,
96
+ themes=themes,
97
+ locales=locales,
98
+ )
99
+
100
+
101
+ @router.get(
102
+ "/locales",
103
+ response_model=list[LocaleInfo],
104
+ summary="Get supported report languages",
105
+ description="List all supported languages for report generation",
106
+ )
107
+ async def list_locales() -> list[LocaleInfo]:
108
+ """Get list of supported locales for report generation.
109
+
110
+ Supports 15 languages as per truthound documentation:
111
+ en, ko, ja, zh, de, fr, es, pt, it, ru, ar, th, vi, id, tr
112
+
113
+ Returns:
114
+ List of locale information.
115
+ """
116
+ locales_raw = get_report_locales()
117
+ return [
118
+ LocaleInfo(
119
+ code=loc["code"],
120
+ english_name=loc["english_name"],
121
+ native_name=loc["native_name"],
122
+ flag=loc["flag"],
123
+ rtl=loc["rtl"],
124
+ )
125
+ for loc in locales_raw
126
+ ]
127
+
128
+
129
+ @router.post(
130
+ "/validations/{validation_id}/report",
131
+ response_model=ReportResponse,
132
+ summary="Generate validation report metadata",
133
+ description="Generate a report and return metadata (use download endpoint for content)",
134
+ )
135
+ async def generate_validation_report(
136
+ service: ValidationServiceDep,
137
+ validation_id: Annotated[str, Path(description="Validation ID")],
138
+ request: ReportGenerateRequest,
139
+ ) -> ReportResponse:
140
+ """Generate a validation report.
141
+
142
+ This endpoint returns report metadata. Use the download endpoint
143
+ to get the actual report content.
144
+
145
+ Args:
146
+ service: Injected validation service.
147
+ validation_id: Validation to generate report for.
148
+ request: Report generation options (including locale).
149
+
150
+ Returns:
151
+ Report metadata and download information.
152
+
153
+ Raises:
154
+ HTTPException: 404 if validation not found.
155
+ HTTPException: 400 if format or locale is not supported.
156
+ """
157
+ # Get validation
158
+ validation = await service.get_validation(validation_id)
159
+ if validation is None:
160
+ raise HTTPException(status_code=404, detail="Validation not found")
161
+
162
+ # Get locale from request (default to English)
163
+ locale = getattr(request, "locale", "en") or "en"
164
+
165
+ try:
166
+ # Generate report
167
+ result = await generate_report(
168
+ validation,
169
+ format=request.format,
170
+ theme=request.theme,
171
+ locale=locale,
172
+ title=request.title,
173
+ include_samples=request.include_samples,
174
+ include_statistics=request.include_statistics,
175
+ custom_metadata=request.custom_metadata,
176
+ )
177
+
178
+ return ReportResponse(
179
+ filename=result.filename,
180
+ content_type=result.content_type,
181
+ size_bytes=result.size_bytes,
182
+ generation_time_ms=result.generation_time_ms,
183
+ metadata=ReportMetadataResponse(
184
+ title=result.metadata.title,
185
+ generated_at=result.metadata.generated_at,
186
+ source_name=result.metadata.source_name,
187
+ source_id=result.metadata.source_id,
188
+ validation_id=result.metadata.validation_id,
189
+ theme=result.metadata.theme.value,
190
+ format=result.metadata.format.value,
191
+ ),
192
+ )
193
+
194
+ except ValueError as e:
195
+ raise HTTPException(status_code=400, detail=str(e))
196
+
197
+
198
+ @router.get(
199
+ "/validations/{validation_id}/download",
200
+ summary="Download validation report",
201
+ description="Generate and download a validation report as a file with language support",
202
+ responses={
203
+ 200: {
204
+ "description": "Report file",
205
+ "content": {
206
+ "text/html": {},
207
+ "text/csv": {},
208
+ "application/json": {},
209
+ "text/markdown": {},
210
+ "application/pdf": {},
211
+ },
212
+ },
213
+ 404: {"description": "Validation not found"},
214
+ 400: {"description": "Invalid format or locale"},
215
+ },
216
+ )
217
+ async def download_validation_report(
218
+ service: ValidationServiceDep,
219
+ validation_id: Annotated[str, Path(description="Validation ID")],
220
+ format: Annotated[str, Query(description="Report format")] = "html",
221
+ theme: Annotated[str, Query(description="Visual theme")] = "professional",
222
+ locale: Annotated[
223
+ str, Query(description="Report language (en, ko, ja, zh, de, fr, es, pt, it, ru, ar, th, vi, id, tr)")
224
+ ] = "en",
225
+ include_samples: Annotated[
226
+ bool, Query(description="Include sample values")
227
+ ] = True,
228
+ include_statistics: Annotated[
229
+ bool, Query(description="Include statistics")
230
+ ] = True,
231
+ ) -> Response:
232
+ """Download a validation report.
233
+
234
+ Generates and returns the report as a downloadable file.
235
+ Supports 15 languages for internationalization.
236
+
237
+ Args:
238
+ service: Injected validation service.
239
+ validation_id: Validation to generate report for.
240
+ format: Report format (html, csv, json, markdown).
241
+ theme: Visual theme for the report.
242
+ locale: Report language code.
243
+ include_samples: Include sample problematic values.
244
+ include_statistics: Include data statistics.
245
+
246
+ Returns:
247
+ Report file as download response.
248
+
249
+ Raises:
250
+ HTTPException: 404 if validation not found.
251
+ HTTPException: 400 if format or locale is not supported.
252
+ """
253
+ # Get validation
254
+ validation = await service.get_validation(validation_id)
255
+ if validation is None:
256
+ raise HTTPException(status_code=404, detail="Validation not found")
257
+
258
+ try:
259
+ # Generate report with locale
260
+ result = await generate_report(
261
+ validation,
262
+ format=format,
263
+ theme=theme,
264
+ locale=locale,
265
+ include_samples=include_samples,
266
+ include_statistics=include_statistics,
267
+ )
268
+
269
+ # Return as file download
270
+ content = result.content
271
+ if isinstance(content, str):
272
+ content = content.encode("utf-8")
273
+
274
+ return Response(
275
+ content=content,
276
+ media_type=result.content_type,
277
+ headers={
278
+ "Content-Disposition": f'attachment; filename="{result.filename}"',
279
+ "Content-Length": str(result.size_bytes),
280
+ },
281
+ )
282
+
283
+ except ValueError as e:
284
+ raise HTTPException(status_code=400, detail=str(e))
285
+
286
+
287
+ @router.get(
288
+ "/validations/{validation_id}/preview",
289
+ summary="Preview validation report",
290
+ description="Generate and return report for inline viewing with language support",
291
+ responses={
292
+ 200: {
293
+ "description": "Report content for preview",
294
+ "content": {
295
+ "text/html": {},
296
+ "text/csv": {},
297
+ "application/json": {},
298
+ "text/markdown": {},
299
+ },
300
+ },
301
+ 404: {"description": "Validation not found"},
302
+ 400: {"description": "Invalid format or locale"},
303
+ },
304
+ )
305
+ async def preview_validation_report(
306
+ service: ValidationServiceDep,
307
+ validation_id: Annotated[str, Path(description="Validation ID")],
308
+ format: Annotated[str, Query(description="Report format")] = "html",
309
+ theme: Annotated[str, Query(description="Visual theme")] = "professional",
310
+ locale: Annotated[
311
+ str, Query(description="Report language (en, ko, ja, zh, etc.)")
312
+ ] = "en",
313
+ ) -> Response:
314
+ """Preview a validation report inline.
315
+
316
+ Similar to download but without Content-Disposition header,
317
+ allowing browser to render the content inline.
318
+ Supports 15 languages for internationalization.
319
+
320
+ Args:
321
+ service: Injected validation service.
322
+ validation_id: Validation to generate report for.
323
+ format: Report format (html, csv, json, markdown).
324
+ theme: Visual theme for the report.
325
+ locale: Report language code.
326
+
327
+ Returns:
328
+ Report content for inline viewing.
329
+
330
+ Raises:
331
+ HTTPException: 404 if validation not found.
332
+ HTTPException: 400 if format or locale is not supported.
333
+ """
334
+ # Get validation
335
+ validation = await service.get_validation(validation_id)
336
+ if validation is None:
337
+ raise HTTPException(status_code=404, detail="Validation not found")
338
+
339
+ try:
340
+ # Generate report with locale
341
+ result = await generate_report(
342
+ validation,
343
+ format=format,
344
+ theme=theme,
345
+ locale=locale,
346
+ include_samples=True,
347
+ include_statistics=True,
348
+ )
349
+
350
+ # Return for inline viewing
351
+ content = result.content
352
+ if isinstance(content, str):
353
+ content = content.encode("utf-8")
354
+
355
+ return Response(
356
+ content=content,
357
+ media_type=result.content_type,
358
+ )
359
+
360
+ except ValueError as e:
361
+ raise HTTPException(status_code=400, detail=str(e))
362
+
363
+
364
+ # =============================================================================
365
+ # Report History Endpoints
366
+ # =============================================================================
367
+
368
+
369
+ @router.get(
370
+ "/history",
371
+ response_model=GeneratedReportListResponse,
372
+ summary="List generated reports",
373
+ description="Get paginated list of generated reports with filtering",
374
+ )
375
+ async def list_report_history(
376
+ service: ReportHistoryServiceDep,
377
+ source_id: Annotated[str | None, Query(description="Filter by source ID")] = None,
378
+ validation_id: Annotated[str | None, Query(description="Filter by validation ID")] = None,
379
+ reporter_id: Annotated[str | None, Query(description="Filter by reporter ID")] = None,
380
+ format: Annotated[str | None, Query(description="Filter by format")] = None,
381
+ status: Annotated[str | None, Query(description="Filter by status")] = None,
382
+ include_expired: Annotated[bool, Query(description="Include expired reports")] = False,
383
+ search: Annotated[str | None, Query(description="Search by name")] = None,
384
+ sort_by: Annotated[str, Query(description="Sort field")] = "created_at",
385
+ sort_order: Annotated[Literal["asc", "desc"], Query(description="Sort order")] = "desc",
386
+ page: Annotated[int, Query(ge=1, description="Page number")] = 1,
387
+ page_size: Annotated[int, Query(ge=1, le=100, description="Items per page")] = 20,
388
+ ) -> GeneratedReportListResponse:
389
+ """List generated reports with filtering and pagination.
390
+
391
+ Args:
392
+ service: Report history service.
393
+ source_id: Filter by source ID.
394
+ validation_id: Filter by validation ID.
395
+ reporter_id: Filter by reporter ID.
396
+ format: Filter by format (html, pdf, csv, json, markdown, junit, excel).
397
+ status: Filter by status (pending, generating, completed, failed, expired).
398
+ include_expired: Include expired reports (default: false).
399
+ search: Search by report name.
400
+ sort_by: Field to sort by.
401
+ sort_order: Sort direction (asc/desc).
402
+ page: Page number.
403
+ page_size: Items per page.
404
+
405
+ Returns:
406
+ Paginated list of generated reports.
407
+ """
408
+ reports, total = await service.list_reports(
409
+ source_id=source_id,
410
+ validation_id=validation_id,
411
+ reporter_id=reporter_id,
412
+ format=format,
413
+ status=status,
414
+ include_expired=include_expired,
415
+ search=search,
416
+ sort_by=sort_by,
417
+ sort_order=sort_order,
418
+ page=page,
419
+ page_size=page_size,
420
+ )
421
+
422
+ items = []
423
+ for report in reports:
424
+ response = GeneratedReportResponse.from_model(report)
425
+ response.download_url = f"/api/v1/reports/history/{report.id}/download"
426
+ items.append(response)
427
+
428
+ return GeneratedReportListResponse(
429
+ items=items,
430
+ total=total,
431
+ page=page,
432
+ page_size=page_size,
433
+ )
434
+
435
+
436
+ @router.get(
437
+ "/history/statistics",
438
+ response_model=ReportStatistics,
439
+ summary="Get report statistics",
440
+ description="Get statistics about generated reports",
441
+ )
442
+ async def get_report_statistics(
443
+ service: ReportHistoryServiceDep,
444
+ ) -> ReportStatistics:
445
+ """Get statistics about generated reports.
446
+
447
+ Args:
448
+ service: Report history service.
449
+
450
+ Returns:
451
+ Report statistics.
452
+ """
453
+ stats = await service.get_statistics()
454
+ return ReportStatistics(**stats)
455
+
456
+
457
+ @router.get(
458
+ "/history/{report_id}",
459
+ response_model=GeneratedReportResponse,
460
+ summary="Get report details",
461
+ description="Get details of a specific generated report",
462
+ )
463
+ async def get_report_history(
464
+ service: ReportHistoryServiceDep,
465
+ report_id: Annotated[str, Path(description="Report ID")],
466
+ ) -> GeneratedReportResponse:
467
+ """Get details of a specific generated report.
468
+
469
+ Args:
470
+ service: Report history service.
471
+ report_id: Report ID.
472
+
473
+ Returns:
474
+ Report details.
475
+
476
+ Raises:
477
+ HTTPException: 404 if report not found.
478
+ """
479
+ report = await service.get_report(report_id)
480
+ if not report:
481
+ raise HTTPException(status_code=404, detail="Report not found")
482
+
483
+ response = GeneratedReportResponse.from_model(report)
484
+ response.download_url = f"/api/v1/reports/history/{report.id}/download"
485
+ return response
486
+
487
+
488
+ @router.post(
489
+ "/history",
490
+ response_model=GeneratedReportResponse,
491
+ status_code=201,
492
+ summary="Create report record",
493
+ description="Create a new report record (without generating content)",
494
+ )
495
+ async def create_report_record(
496
+ service: ReportHistoryServiceDep,
497
+ request: GeneratedReportCreate,
498
+ ) -> GeneratedReportResponse:
499
+ """Create a new report record.
500
+
501
+ This creates a record in pending state. Use the generate endpoint
502
+ to actually generate the report content.
503
+
504
+ Args:
505
+ service: Report history service.
506
+ request: Report creation data.
507
+
508
+ Returns:
509
+ Created report record.
510
+ """
511
+ report = await service.create_report(
512
+ name=request.name,
513
+ format=request.format,
514
+ validation_id=request.validation_id,
515
+ source_id=request.source_id,
516
+ reporter_id=request.reporter_id,
517
+ description=request.description,
518
+ theme=request.theme,
519
+ locale=request.locale,
520
+ config=request.config,
521
+ metadata=request.metadata,
522
+ expires_in_days=request.expires_in_days,
523
+ )
524
+
525
+ response = GeneratedReportResponse.from_model(report)
526
+ response.download_url = f"/api/v1/reports/history/{report.id}/download"
527
+ return response
528
+
529
+
530
+ @router.patch(
531
+ "/history/{report_id}",
532
+ response_model=GeneratedReportResponse,
533
+ summary="Update report record",
534
+ description="Update a report record's metadata",
535
+ )
536
+ async def update_report_record(
537
+ service: ReportHistoryServiceDep,
538
+ report_id: Annotated[str, Path(description="Report ID")],
539
+ request: GeneratedReportUpdate,
540
+ ) -> GeneratedReportResponse:
541
+ """Update a report record.
542
+
543
+ Only name, description, and metadata can be updated.
544
+
545
+ Args:
546
+ service: Report history service.
547
+ report_id: Report ID.
548
+ request: Update data.
549
+
550
+ Returns:
551
+ Updated report record.
552
+
553
+ Raises:
554
+ HTTPException: 404 if report not found.
555
+ """
556
+ report = await service.update_report(
557
+ report_id=report_id,
558
+ name=request.name,
559
+ description=request.description,
560
+ metadata=request.metadata,
561
+ )
562
+ if not report:
563
+ raise HTTPException(status_code=404, detail="Report not found")
564
+
565
+ response = GeneratedReportResponse.from_model(report)
566
+ response.download_url = f"/api/v1/reports/history/{report.id}/download"
567
+ return response
568
+
569
+
570
+ @router.delete(
571
+ "/history/{report_id}",
572
+ status_code=204,
573
+ summary="Delete report record",
574
+ description="Delete a report record and its file",
575
+ )
576
+ async def delete_report_record(
577
+ service: ReportHistoryServiceDep,
578
+ report_id: Annotated[str, Path(description="Report ID")],
579
+ ) -> None:
580
+ """Delete a report record and its file.
581
+
582
+ Args:
583
+ service: Report history service.
584
+ report_id: Report ID.
585
+
586
+ Raises:
587
+ HTTPException: 404 if report not found.
588
+ """
589
+ deleted = await service.delete_report(report_id)
590
+ if not deleted:
591
+ raise HTTPException(status_code=404, detail="Report not found")
592
+
593
+
594
+ @router.get(
595
+ "/history/{report_id}/download",
596
+ summary="Download saved report",
597
+ description="Download a previously generated and saved report",
598
+ responses={
599
+ 200: {
600
+ "description": "Report file",
601
+ "content": {
602
+ "text/html": {},
603
+ "text/csv": {},
604
+ "application/json": {},
605
+ "text/markdown": {},
606
+ "application/pdf": {},
607
+ },
608
+ },
609
+ 404: {"description": "Report not found or file missing"},
610
+ },
611
+ )
612
+ async def download_saved_report(
613
+ service: ReportHistoryServiceDep,
614
+ report_id: Annotated[str, Path(description="Report ID")],
615
+ ) -> Response:
616
+ """Download a previously generated report.
617
+
618
+ Args:
619
+ service: Report history service.
620
+ report_id: Report ID.
621
+
622
+ Returns:
623
+ Report file as download.
624
+
625
+ Raises:
626
+ HTTPException: 404 if report or file not found.
627
+ """
628
+ report = await service.get_report(report_id)
629
+ if not report:
630
+ raise HTTPException(status_code=404, detail="Report not found")
631
+
632
+ if report.status != ReportStatus.COMPLETED:
633
+ raise HTTPException(
634
+ status_code=400,
635
+ detail=f"Report is not ready (status: {report.status})",
636
+ )
637
+
638
+ content, content_type = await service.get_report_content(report_id)
639
+ if not content:
640
+ raise HTTPException(status_code=404, detail="Report file not found")
641
+
642
+ # Record download
643
+ await service.record_download(report_id)
644
+
645
+ # Build filename
646
+ ext_map = {
647
+ "html": ".html",
648
+ "pdf": ".pdf",
649
+ "csv": ".csv",
650
+ "json": ".json",
651
+ "markdown": ".md",
652
+ "junit": ".xml",
653
+ "excel": ".xlsx",
654
+ }
655
+ fmt = report.format.value if hasattr(report.format, "value") else report.format
656
+ ext = ext_map.get(fmt, ".html")
657
+ filename = f"{report.name}{ext}"
658
+
659
+ return Response(
660
+ content=content,
661
+ media_type=content_type,
662
+ headers={
663
+ "Content-Disposition": f'attachment; filename="{filename}"',
664
+ "Content-Length": str(len(content)),
665
+ },
666
+ )
667
+
668
+
669
+ @router.post(
670
+ "/history/{report_id}/generate",
671
+ response_model=GeneratedReportResponse,
672
+ summary="Generate report content",
673
+ description="Generate content for an existing report record",
674
+ )
675
+ async def generate_report_content(
676
+ service: ReportHistoryServiceDep,
677
+ validation_service: ValidationServiceDep,
678
+ report_id: Annotated[str, Path(description="Report ID")],
679
+ ) -> GeneratedReportResponse:
680
+ """Generate content for an existing report record.
681
+
682
+ Args:
683
+ service: Report history service.
684
+ validation_service: Validation service.
685
+ report_id: Report ID.
686
+
687
+ Returns:
688
+ Updated report with generation status.
689
+
690
+ Raises:
691
+ HTTPException: 404 if report not found.
692
+ HTTPException: 400 if report cannot be generated.
693
+ """
694
+ report = await service.get_report(report_id)
695
+ if not report:
696
+ raise HTTPException(status_code=404, detail="Report not found")
697
+
698
+ if report.status == ReportStatus.COMPLETED:
699
+ raise HTTPException(status_code=400, detail="Report already generated")
700
+
701
+ if not report.validation_id:
702
+ raise HTTPException(
703
+ status_code=400,
704
+ detail="Report has no associated validation",
705
+ )
706
+
707
+ # Get validation
708
+ validation = await validation_service.get_validation(report.validation_id)
709
+ if not validation:
710
+ raise HTTPException(status_code=404, detail="Associated validation not found")
711
+
712
+ # Mark as generating
713
+ await service.mark_generating(report_id)
714
+
715
+ try:
716
+ # Generate report
717
+ start_time = time.time()
718
+ fmt = report.format.value if hasattr(report.format, "value") else report.format
719
+ result = await generate_report(
720
+ validation,
721
+ format=fmt,
722
+ theme=report.theme or "professional",
723
+ locale=report.locale,
724
+ )
725
+ generation_time_ms = (time.time() - start_time) * 1000
726
+
727
+ # Store content
728
+ content = result.content
729
+ if isinstance(content, str):
730
+ content = content.encode("utf-8")
731
+
732
+ report = await service.mark_completed(report_id, content, generation_time_ms)
733
+
734
+ except Exception as e:
735
+ await service.mark_failed(report_id, str(e))
736
+ raise HTTPException(status_code=500, detail=f"Report generation failed: {e}")
737
+
738
+ response = GeneratedReportResponse.from_model(report)
739
+ response.download_url = f"/api/v1/reports/history/{report.id}/download"
740
+ return response
741
+
742
+
743
+ @router.post(
744
+ "/history/cleanup",
745
+ summary="Cleanup expired reports",
746
+ description="Delete all expired reports and their files",
747
+ )
748
+ async def cleanup_expired_reports(
749
+ service: ReportHistoryServiceDep,
750
+ ) -> dict[str, int]:
751
+ """Delete all expired reports.
752
+
753
+ Args:
754
+ service: Report history service.
755
+
756
+ Returns:
757
+ Number of reports deleted.
758
+ """
759
+ count = await service.cleanup_expired()
760
+ return {"deleted": count}
761
+
762
+
763
+ @router.post(
764
+ "/bulk",
765
+ response_model=BulkReportGenerateResponse,
766
+ summary="Generate reports in bulk",
767
+ description="Generate reports for multiple validations at once",
768
+ )
769
+ async def generate_bulk_reports(
770
+ service: ReportHistoryServiceDep,
771
+ validation_service: ValidationServiceDep,
772
+ request: BulkReportGenerateRequest,
773
+ ) -> BulkReportGenerateResponse:
774
+ """Generate reports for multiple validations.
775
+
776
+ Args:
777
+ service: Report history service.
778
+ validation_service: Validation service.
779
+ request: Bulk generation request.
780
+
781
+ Returns:
782
+ Bulk generation results.
783
+ """
784
+ reports = []
785
+ errors = []
786
+ successful = 0
787
+ failed = 0
788
+
789
+ for validation_id in request.validation_ids:
790
+ try:
791
+ # Get validation
792
+ validation = await validation_service.get_validation(validation_id)
793
+ if not validation:
794
+ errors.append({
795
+ "validation_id": validation_id,
796
+ "error": "Validation not found",
797
+ })
798
+ failed += 1
799
+ continue
800
+
801
+ # Create report record
802
+ report = await service.create_report(
803
+ name=f"Validation Report - {validation_id[:8]}",
804
+ format=request.format,
805
+ validation_id=validation_id,
806
+ source_id=str(validation.source_id),
807
+ reporter_id=request.reporter_id,
808
+ theme=request.theme,
809
+ locale=request.locale,
810
+ config=request.config,
811
+ expires_in_days=request.expires_in_days,
812
+ )
813
+
814
+ if request.save_to_history:
815
+ # Mark as generating
816
+ await service.mark_generating(str(report.id))
817
+
818
+ # Generate report
819
+ start_time = time.time()
820
+ result = await generate_report(
821
+ validation,
822
+ format=request.format,
823
+ theme=request.theme,
824
+ locale=request.locale,
825
+ )
826
+ generation_time_ms = (time.time() - start_time) * 1000
827
+
828
+ # Store content
829
+ content = result.content
830
+ if isinstance(content, str):
831
+ content = content.encode("utf-8")
832
+
833
+ report = await service.mark_completed(str(report.id), content, generation_time_ms)
834
+
835
+ response = GeneratedReportResponse.from_model(report)
836
+ response.download_url = f"/api/v1/reports/history/{report.id}/download"
837
+ reports.append(response)
838
+ successful += 1
839
+
840
+ except Exception as e:
841
+ errors.append({
842
+ "validation_id": validation_id,
843
+ "error": str(e),
844
+ })
845
+ failed += 1
846
+
847
+ return BulkReportGenerateResponse(
848
+ total=len(request.validation_ids),
849
+ successful=successful,
850
+ failed=failed,
851
+ reports=reports,
852
+ errors=errors,
853
+ )