truthound-dashboard 1.3.0__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.
- 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.0.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -18
- truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
- truthound_dashboard/static/assets/index-BCA8H1hO.js +0 -574
- truthound_dashboard/static/assets/index-BNsSQ2fN.css +0 -1
- truthound_dashboard/static/assets/unmerged_dictionaries-CsJWCRx9.js +0 -1
- truthound_dashboard-1.3.0.dist-info/RECORD +0 -110
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
- {truthound_dashboard-1.3.0.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
- {truthound_dashboard-1.3.0.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
|
+
)
|