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,2096 @@
|
|
|
1
|
+
"""Plugin System API endpoints.
|
|
2
|
+
|
|
3
|
+
This module provides REST API endpoints for:
|
|
4
|
+
- Plugin marketplace (discovery, search, install)
|
|
5
|
+
- Custom validators (CRUD, test, execute)
|
|
6
|
+
- Custom reporters (CRUD, preview, generate)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Annotated, Any
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
|
|
17
|
+
from truthound_dashboard.core.plugins import (
|
|
18
|
+
CustomReporterExecutor,
|
|
19
|
+
CustomValidatorExecutor,
|
|
20
|
+
PluginLoader,
|
|
21
|
+
PluginRegistry,
|
|
22
|
+
PluginSecurityManager,
|
|
23
|
+
)
|
|
24
|
+
from truthound_dashboard.core.plugins.registry import plugin_registry
|
|
25
|
+
from truthound_dashboard.core.plugins.reporter_executor import ReportContext
|
|
26
|
+
from truthound_dashboard.core.plugins.validator_executor import ValidatorContext
|
|
27
|
+
from truthound_dashboard.db.models import PluginStatus as DBPluginStatus
|
|
28
|
+
from truthound_dashboard.db.models import PluginType as DBPluginType
|
|
29
|
+
from truthound_dashboard.db.models import Validation
|
|
30
|
+
from truthound_dashboard.schemas.plugins import (
|
|
31
|
+
AddSignerRequest,
|
|
32
|
+
CodeAnalysisResult,
|
|
33
|
+
CustomReporterCreate,
|
|
34
|
+
CustomReporterListResponse,
|
|
35
|
+
CustomReporterResponse,
|
|
36
|
+
CustomReporterUpdate,
|
|
37
|
+
CustomValidatorCreate,
|
|
38
|
+
CustomValidatorListResponse,
|
|
39
|
+
CustomValidatorResponse,
|
|
40
|
+
CustomValidatorUpdate,
|
|
41
|
+
DependencyGraphResponse,
|
|
42
|
+
DependencyResolutionRequest,
|
|
43
|
+
DependencyResolutionResponse,
|
|
44
|
+
DocumentationRenderRequest,
|
|
45
|
+
DocumentationRenderResponse,
|
|
46
|
+
ExtendedSecurityReport,
|
|
47
|
+
HookListResponse,
|
|
48
|
+
HookRegistration,
|
|
49
|
+
HookType,
|
|
50
|
+
HotReloadConfigRequest,
|
|
51
|
+
HotReloadResult,
|
|
52
|
+
HotReloadStatus,
|
|
53
|
+
MarketplaceSearchRequest,
|
|
54
|
+
MarketplaceStats,
|
|
55
|
+
PluginCreate,
|
|
56
|
+
PluginDocumentation,
|
|
57
|
+
PluginInstallRequest,
|
|
58
|
+
PluginInstallResponse,
|
|
59
|
+
PluginLifecycleResponse,
|
|
60
|
+
PluginListResponse,
|
|
61
|
+
PluginResponse,
|
|
62
|
+
PluginState,
|
|
63
|
+
PluginStatus,
|
|
64
|
+
PluginSummary,
|
|
65
|
+
PluginTransitionRequest,
|
|
66
|
+
PluginTransitionResponse,
|
|
67
|
+
PluginType,
|
|
68
|
+
PluginUninstallRequest,
|
|
69
|
+
PluginUninstallResponse,
|
|
70
|
+
PluginUpdate,
|
|
71
|
+
PluginUpdateCheckResponse,
|
|
72
|
+
RegisterHookRequest,
|
|
73
|
+
ReporterGenerateRequest,
|
|
74
|
+
ReporterGenerateResponse,
|
|
75
|
+
SecurityAnalysisRequest,
|
|
76
|
+
SecurityPolicyConfig,
|
|
77
|
+
TrustStoreResponse,
|
|
78
|
+
TrustedSigner,
|
|
79
|
+
ValidatorTestRequest,
|
|
80
|
+
ValidatorTestResponse,
|
|
81
|
+
VerifySignatureRequest,
|
|
82
|
+
VerifySignatureResponse,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
from .deps import get_session
|
|
86
|
+
|
|
87
|
+
logger = logging.getLogger(__name__)
|
|
88
|
+
|
|
89
|
+
router = APIRouter()
|
|
90
|
+
|
|
91
|
+
# Dependencies
|
|
92
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# =============================================================================
|
|
96
|
+
# Plugin Marketplace Endpoints
|
|
97
|
+
# =============================================================================
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.get("/plugins", response_model=PluginListResponse)
|
|
101
|
+
async def list_plugins(
|
|
102
|
+
session: SessionDep,
|
|
103
|
+
type: PluginType | None = None,
|
|
104
|
+
status: PluginStatus | None = None,
|
|
105
|
+
search: str | None = None,
|
|
106
|
+
offset: int = Query(default=0, ge=0),
|
|
107
|
+
limit: int = Query(default=20, ge=1, le=100),
|
|
108
|
+
) -> PluginListResponse:
|
|
109
|
+
"""List all plugins with optional filtering.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
session: Database session.
|
|
113
|
+
type: Filter by plugin type.
|
|
114
|
+
status: Filter by status.
|
|
115
|
+
search: Search in name, display_name, description.
|
|
116
|
+
offset: Pagination offset.
|
|
117
|
+
limit: Pagination limit.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
List of plugins.
|
|
121
|
+
"""
|
|
122
|
+
db_type = DBPluginType(type.value) if type else None
|
|
123
|
+
db_status = DBPluginStatus(status.value) if status else None
|
|
124
|
+
|
|
125
|
+
plugins, total = await plugin_registry.list_plugins(
|
|
126
|
+
session=session,
|
|
127
|
+
plugin_type=db_type,
|
|
128
|
+
status=db_status,
|
|
129
|
+
search=search,
|
|
130
|
+
offset=offset,
|
|
131
|
+
limit=limit,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return PluginListResponse(
|
|
135
|
+
data=[PluginResponse.from_model(p) for p in plugins],
|
|
136
|
+
total=total,
|
|
137
|
+
offset=offset,
|
|
138
|
+
limit=limit,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@router.get("/plugins/stats", response_model=MarketplaceStats)
|
|
143
|
+
async def get_marketplace_stats(session: SessionDep) -> MarketplaceStats:
|
|
144
|
+
"""Get marketplace statistics.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
session: Database session.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Marketplace statistics.
|
|
151
|
+
"""
|
|
152
|
+
stats = await plugin_registry.get_statistics(session)
|
|
153
|
+
|
|
154
|
+
return MarketplaceStats(
|
|
155
|
+
total_plugins=stats["total_plugins"],
|
|
156
|
+
total_validators=stats["total_validators"],
|
|
157
|
+
total_reporters=stats["total_reporters"],
|
|
158
|
+
total_installs=0, # Could track this separately
|
|
159
|
+
categories=[],
|
|
160
|
+
featured_plugins=[],
|
|
161
|
+
popular_plugins=[],
|
|
162
|
+
recent_plugins=[],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.post("/plugins/search")
|
|
167
|
+
async def search_plugins(
|
|
168
|
+
session: SessionDep,
|
|
169
|
+
request: MarketplaceSearchRequest,
|
|
170
|
+
) -> PluginListResponse:
|
|
171
|
+
"""Search plugins in marketplace.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
session: Database session.
|
|
175
|
+
request: Search request.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
List of matching plugins.
|
|
179
|
+
"""
|
|
180
|
+
# Convert types
|
|
181
|
+
db_types = [DBPluginType(t.value) for t in request.types] if request.types else None
|
|
182
|
+
|
|
183
|
+
plugins, total = await plugin_registry.list_plugins(
|
|
184
|
+
session=session,
|
|
185
|
+
plugin_type=db_types[0] if db_types and len(db_types) == 1 else None,
|
|
186
|
+
search=request.query,
|
|
187
|
+
offset=request.offset,
|
|
188
|
+
limit=request.limit,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return PluginListResponse(
|
|
192
|
+
data=[PluginResponse.from_model(p) for p in plugins],
|
|
193
|
+
total=total,
|
|
194
|
+
offset=request.offset,
|
|
195
|
+
limit=request.limit,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@router.get("/plugins/{plugin_id}", response_model=PluginResponse)
|
|
200
|
+
async def get_plugin(
|
|
201
|
+
session: SessionDep,
|
|
202
|
+
plugin_id: str,
|
|
203
|
+
) -> PluginResponse:
|
|
204
|
+
"""Get a plugin by ID.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
session: Database session.
|
|
208
|
+
plugin_id: Plugin ID.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Plugin details.
|
|
212
|
+
"""
|
|
213
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
214
|
+
if not plugin:
|
|
215
|
+
raise HTTPException(
|
|
216
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
217
|
+
detail=f"Plugin {plugin_id} not found",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return PluginResponse.from_model(plugin)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@router.post("/plugins", response_model=PluginResponse, status_code=status.HTTP_201_CREATED)
|
|
224
|
+
async def register_plugin(
|
|
225
|
+
session: SessionDep,
|
|
226
|
+
request: PluginCreate,
|
|
227
|
+
) -> PluginResponse:
|
|
228
|
+
"""Register a new plugin.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
session: Database session.
|
|
232
|
+
request: Plugin creation request.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Created plugin.
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
plugin = await plugin_registry.register_plugin(
|
|
239
|
+
session=session,
|
|
240
|
+
name=request.name,
|
|
241
|
+
display_name=request.display_name,
|
|
242
|
+
description=request.description,
|
|
243
|
+
version=request.version,
|
|
244
|
+
plugin_type=DBPluginType(request.type.value),
|
|
245
|
+
source=request.source.value,
|
|
246
|
+
author=request.author.model_dump() if request.author else None,
|
|
247
|
+
license=request.license,
|
|
248
|
+
homepage=request.homepage,
|
|
249
|
+
repository=request.repository,
|
|
250
|
+
keywords=request.keywords,
|
|
251
|
+
categories=request.categories,
|
|
252
|
+
dependencies=[d.model_dump() for d in request.dependencies],
|
|
253
|
+
permissions=[p.value for p in request.permissions],
|
|
254
|
+
python_version=request.python_version,
|
|
255
|
+
dashboard_version=request.dashboard_version,
|
|
256
|
+
icon_url=request.icon_url,
|
|
257
|
+
banner_url=request.banner_url,
|
|
258
|
+
documentation_url=request.documentation_url,
|
|
259
|
+
changelog=request.changelog,
|
|
260
|
+
readme=request.readme,
|
|
261
|
+
)
|
|
262
|
+
await session.commit()
|
|
263
|
+
return PluginResponse.from_model(plugin)
|
|
264
|
+
except ValueError as e:
|
|
265
|
+
raise HTTPException(
|
|
266
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
267
|
+
detail=str(e),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@router.patch("/plugins/{plugin_id}", response_model=PluginResponse)
|
|
272
|
+
async def update_plugin(
|
|
273
|
+
session: SessionDep,
|
|
274
|
+
plugin_id: str,
|
|
275
|
+
request: PluginUpdate,
|
|
276
|
+
) -> PluginResponse:
|
|
277
|
+
"""Update a plugin.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
session: Database session.
|
|
281
|
+
plugin_id: Plugin ID.
|
|
282
|
+
request: Update request.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Updated plugin.
|
|
286
|
+
"""
|
|
287
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
288
|
+
if not plugin:
|
|
289
|
+
raise HTTPException(
|
|
290
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
291
|
+
detail=f"Plugin {plugin_id} not found",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Update fields
|
|
295
|
+
for field, value in request.model_dump(exclude_unset=True).items():
|
|
296
|
+
if hasattr(plugin, field) and value is not None:
|
|
297
|
+
setattr(plugin, field, value)
|
|
298
|
+
|
|
299
|
+
await session.commit()
|
|
300
|
+
return PluginResponse.from_model(plugin)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@router.post("/plugins/{plugin_id}/install", response_model=PluginInstallResponse)
|
|
304
|
+
async def install_plugin(
|
|
305
|
+
session: SessionDep,
|
|
306
|
+
plugin_id: str,
|
|
307
|
+
request: PluginInstallRequest | None = None,
|
|
308
|
+
) -> PluginInstallResponse:
|
|
309
|
+
"""Install a plugin.
|
|
310
|
+
|
|
311
|
+
This endpoint performs security verification before installation:
|
|
312
|
+
- Checks plugin signature if available
|
|
313
|
+
- Analyzes code for security issues
|
|
314
|
+
- Validates sandbox compatibility
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
session: Database session.
|
|
318
|
+
plugin_id: Plugin ID.
|
|
319
|
+
request: Install request.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Installation result with security warnings if applicable.
|
|
323
|
+
"""
|
|
324
|
+
# Get plugin first for security analysis
|
|
325
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
326
|
+
if not plugin:
|
|
327
|
+
return PluginInstallResponse(
|
|
328
|
+
success=False,
|
|
329
|
+
plugin_id=plugin_id,
|
|
330
|
+
message=f"Plugin {plugin_id} not found",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
warnings: list[str] = []
|
|
334
|
+
|
|
335
|
+
# Perform security analysis
|
|
336
|
+
try:
|
|
337
|
+
security_manager = PluginSecurityManager()
|
|
338
|
+
security_report = await security_manager.analyze_plugin(
|
|
339
|
+
plugin_id=plugin_id,
|
|
340
|
+
code=None, # Would be actual plugin code in real implementation
|
|
341
|
+
permissions=[p for p in (plugin.permissions or [])],
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Add warnings based on security analysis
|
|
345
|
+
if not security_report.signature_valid:
|
|
346
|
+
warnings.append("Plugin signature could not be verified")
|
|
347
|
+
|
|
348
|
+
if security_report.risk_level == "unverified":
|
|
349
|
+
warnings.append("This plugin has not been verified by the maintainers")
|
|
350
|
+
|
|
351
|
+
if not security_report.sandbox_compatible:
|
|
352
|
+
warnings.append("Plugin may have limited functionality in sandbox mode")
|
|
353
|
+
|
|
354
|
+
for issue in security_report.issues:
|
|
355
|
+
warnings.append(f"Security issue: {issue}")
|
|
356
|
+
|
|
357
|
+
for warning in security_report.warnings:
|
|
358
|
+
warnings.append(warning)
|
|
359
|
+
|
|
360
|
+
except Exception as e:
|
|
361
|
+
logger.warning(f"Security analysis failed for plugin {plugin_id}: {e}")
|
|
362
|
+
warnings.append("Security analysis could not be completed")
|
|
363
|
+
|
|
364
|
+
# Proceed with installation
|
|
365
|
+
try:
|
|
366
|
+
enable = request.enable_after_install if request else True
|
|
367
|
+
skip_verification = request.skip_verification if request else False
|
|
368
|
+
|
|
369
|
+
# Block installation if there are critical security issues and verification not skipped
|
|
370
|
+
if warnings and not skip_verification and plugin.security_level == "unverified":
|
|
371
|
+
return PluginInstallResponse(
|
|
372
|
+
success=False,
|
|
373
|
+
plugin_id=plugin_id,
|
|
374
|
+
message="Installation blocked due to security concerns. Set skip_verification=true to override.",
|
|
375
|
+
warnings=warnings,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
plugin = await plugin_registry.install_plugin(
|
|
379
|
+
session=session,
|
|
380
|
+
plugin_id=plugin_id,
|
|
381
|
+
enable=enable,
|
|
382
|
+
)
|
|
383
|
+
await session.commit()
|
|
384
|
+
|
|
385
|
+
return PluginInstallResponse(
|
|
386
|
+
success=True,
|
|
387
|
+
plugin_id=plugin_id,
|
|
388
|
+
installed_version=plugin.version,
|
|
389
|
+
message=f"Plugin {plugin.name} v{plugin.version} installed successfully",
|
|
390
|
+
warnings=warnings if warnings else None,
|
|
391
|
+
)
|
|
392
|
+
except ValueError as e:
|
|
393
|
+
return PluginInstallResponse(
|
|
394
|
+
success=False,
|
|
395
|
+
plugin_id=plugin_id,
|
|
396
|
+
message=str(e),
|
|
397
|
+
warnings=warnings if warnings else None,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@router.post("/plugins/{plugin_id}/uninstall", response_model=PluginUninstallResponse)
|
|
402
|
+
async def uninstall_plugin(
|
|
403
|
+
session: SessionDep,
|
|
404
|
+
plugin_id: str,
|
|
405
|
+
request: PluginUninstallRequest | None = None,
|
|
406
|
+
) -> PluginUninstallResponse:
|
|
407
|
+
"""Uninstall a plugin.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
session: Database session.
|
|
411
|
+
plugin_id: Plugin ID.
|
|
412
|
+
request: Uninstall request.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Uninstallation result.
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
remove_data = request.remove_data if request else False
|
|
419
|
+
await plugin_registry.uninstall_plugin(
|
|
420
|
+
session=session,
|
|
421
|
+
plugin_id=plugin_id,
|
|
422
|
+
remove_data=remove_data,
|
|
423
|
+
)
|
|
424
|
+
await session.commit()
|
|
425
|
+
|
|
426
|
+
return PluginUninstallResponse(
|
|
427
|
+
success=True,
|
|
428
|
+
plugin_id=plugin_id,
|
|
429
|
+
message="Plugin uninstalled successfully",
|
|
430
|
+
)
|
|
431
|
+
except ValueError as e:
|
|
432
|
+
return PluginUninstallResponse(
|
|
433
|
+
success=False,
|
|
434
|
+
plugin_id=plugin_id,
|
|
435
|
+
message=str(e),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@router.post("/plugins/{plugin_id}/enable", response_model=PluginResponse)
|
|
440
|
+
async def enable_plugin(
|
|
441
|
+
session: SessionDep,
|
|
442
|
+
plugin_id: str,
|
|
443
|
+
) -> PluginResponse:
|
|
444
|
+
"""Enable a plugin.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
session: Database session.
|
|
448
|
+
plugin_id: Plugin ID.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Updated plugin.
|
|
452
|
+
"""
|
|
453
|
+
try:
|
|
454
|
+
plugin = await plugin_registry.enable_plugin(session, plugin_id)
|
|
455
|
+
await session.commit()
|
|
456
|
+
return PluginResponse.from_model(plugin)
|
|
457
|
+
except ValueError as e:
|
|
458
|
+
raise HTTPException(
|
|
459
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
460
|
+
detail=str(e),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@router.post("/plugins/{plugin_id}/disable", response_model=PluginResponse)
|
|
465
|
+
async def disable_plugin(
|
|
466
|
+
session: SessionDep,
|
|
467
|
+
plugin_id: str,
|
|
468
|
+
) -> PluginResponse:
|
|
469
|
+
"""Disable a plugin.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
session: Database session.
|
|
473
|
+
plugin_id: Plugin ID.
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Updated plugin.
|
|
477
|
+
"""
|
|
478
|
+
try:
|
|
479
|
+
plugin = await plugin_registry.disable_plugin(session, plugin_id)
|
|
480
|
+
await session.commit()
|
|
481
|
+
return PluginResponse.from_model(plugin)
|
|
482
|
+
except ValueError as e:
|
|
483
|
+
raise HTTPException(
|
|
484
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
485
|
+
detail=str(e),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@router.get("/plugins/{plugin_id}/check-update", response_model=PluginUpdateCheckResponse)
|
|
490
|
+
async def check_plugin_update(
|
|
491
|
+
session: SessionDep,
|
|
492
|
+
plugin_id: str,
|
|
493
|
+
) -> PluginUpdateCheckResponse:
|
|
494
|
+
"""Check if a plugin has an update available.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
session: Database session.
|
|
498
|
+
plugin_id: Plugin ID.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Update check response with version info.
|
|
502
|
+
"""
|
|
503
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
504
|
+
if not plugin:
|
|
505
|
+
raise HTTPException(
|
|
506
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
507
|
+
detail=f"Plugin {plugin_id} not found",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Check for updates (compare versions)
|
|
511
|
+
has_update = False
|
|
512
|
+
latest_version = plugin.version
|
|
513
|
+
|
|
514
|
+
# In a real implementation, this would check a plugin registry/marketplace
|
|
515
|
+
# For now, we check if latest_version is set and different from current
|
|
516
|
+
if hasattr(plugin, "latest_version") and plugin.latest_version:
|
|
517
|
+
from packaging import version as pkg_version
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
current = pkg_version.parse(plugin.version)
|
|
521
|
+
latest = pkg_version.parse(plugin.latest_version)
|
|
522
|
+
has_update = latest > current
|
|
523
|
+
latest_version = plugin.latest_version
|
|
524
|
+
except Exception:
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
return PluginUpdateCheckResponse(
|
|
528
|
+
plugin_id=plugin_id,
|
|
529
|
+
current_version=plugin.version,
|
|
530
|
+
latest_version=latest_version,
|
|
531
|
+
has_update=has_update,
|
|
532
|
+
changelog=plugin.changelog if has_update else None,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@router.post("/plugins/{plugin_id}/update", response_model=PluginInstallResponse)
|
|
537
|
+
async def update_plugin(
|
|
538
|
+
session: SessionDep,
|
|
539
|
+
plugin_id: str,
|
|
540
|
+
) -> PluginInstallResponse:
|
|
541
|
+
"""Update a plugin to the latest version.
|
|
542
|
+
|
|
543
|
+
Args:
|
|
544
|
+
session: Database session.
|
|
545
|
+
plugin_id: Plugin ID.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
Update result.
|
|
549
|
+
"""
|
|
550
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
551
|
+
if not plugin:
|
|
552
|
+
raise HTTPException(
|
|
553
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
554
|
+
detail=f"Plugin {plugin_id} not found",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Check if plugin is installed
|
|
558
|
+
if plugin.status == DBPluginStatus.available:
|
|
559
|
+
return PluginInstallResponse(
|
|
560
|
+
success=False,
|
|
561
|
+
plugin_id=plugin_id,
|
|
562
|
+
message="Plugin is not installed",
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Check for updates
|
|
566
|
+
if not hasattr(plugin, "latest_version") or not plugin.latest_version:
|
|
567
|
+
return PluginInstallResponse(
|
|
568
|
+
success=False,
|
|
569
|
+
plugin_id=plugin_id,
|
|
570
|
+
installed_version=plugin.version,
|
|
571
|
+
message="No update available",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
from packaging import version as pkg_version
|
|
576
|
+
|
|
577
|
+
current = pkg_version.parse(plugin.version)
|
|
578
|
+
latest = pkg_version.parse(plugin.latest_version)
|
|
579
|
+
|
|
580
|
+
if latest <= current:
|
|
581
|
+
return PluginInstallResponse(
|
|
582
|
+
success=False,
|
|
583
|
+
plugin_id=plugin_id,
|
|
584
|
+
installed_version=plugin.version,
|
|
585
|
+
message="Already at latest version",
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
# Perform update (in real implementation, this would download and install)
|
|
589
|
+
old_version = plugin.version
|
|
590
|
+
plugin.version = plugin.latest_version
|
|
591
|
+
plugin.latest_version = None
|
|
592
|
+
plugin.status = DBPluginStatus.enabled if plugin.is_enabled else DBPluginStatus.installed
|
|
593
|
+
|
|
594
|
+
await session.commit()
|
|
595
|
+
|
|
596
|
+
return PluginInstallResponse(
|
|
597
|
+
success=True,
|
|
598
|
+
plugin_id=plugin_id,
|
|
599
|
+
installed_version=plugin.version,
|
|
600
|
+
message=f"Plugin updated from v{old_version} to v{plugin.version}",
|
|
601
|
+
)
|
|
602
|
+
except Exception as e:
|
|
603
|
+
logger.error(f"Failed to update plugin {plugin_id}: {e}")
|
|
604
|
+
return PluginInstallResponse(
|
|
605
|
+
success=False,
|
|
606
|
+
plugin_id=plugin_id,
|
|
607
|
+
installed_version=plugin.version,
|
|
608
|
+
message=f"Update failed: {str(e)}",
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# =============================================================================
|
|
613
|
+
# Custom Validator Endpoints
|
|
614
|
+
# =============================================================================
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@router.get("/validators/custom", response_model=CustomValidatorListResponse)
|
|
618
|
+
async def list_custom_validators(
|
|
619
|
+
session: SessionDep,
|
|
620
|
+
plugin_id: str | None = None,
|
|
621
|
+
category: str | None = None,
|
|
622
|
+
enabled_only: bool = False,
|
|
623
|
+
search: str | None = None,
|
|
624
|
+
offset: int = Query(default=0, ge=0),
|
|
625
|
+
limit: int = Query(default=20, ge=1, le=100),
|
|
626
|
+
) -> CustomValidatorListResponse:
|
|
627
|
+
"""List custom validators.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
session: Database session.
|
|
631
|
+
plugin_id: Filter by plugin.
|
|
632
|
+
category: Filter by category.
|
|
633
|
+
enabled_only: Only return enabled validators.
|
|
634
|
+
search: Search query.
|
|
635
|
+
offset: Pagination offset.
|
|
636
|
+
limit: Pagination limit.
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
List of custom validators.
|
|
640
|
+
"""
|
|
641
|
+
validators, total = await plugin_registry.list_validators(
|
|
642
|
+
session=session,
|
|
643
|
+
plugin_id=plugin_id,
|
|
644
|
+
category=category,
|
|
645
|
+
enabled_only=enabled_only,
|
|
646
|
+
search=search,
|
|
647
|
+
offset=offset,
|
|
648
|
+
limit=limit,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
return CustomValidatorListResponse(
|
|
652
|
+
data=[CustomValidatorResponse.from_model(v) for v in validators],
|
|
653
|
+
total=total,
|
|
654
|
+
offset=offset,
|
|
655
|
+
limit=limit,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@router.get("/validators/custom/categories")
|
|
660
|
+
async def list_validator_categories(session: SessionDep) -> list[str]:
|
|
661
|
+
"""List all validator categories.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
session: Database session.
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
List of category names.
|
|
668
|
+
"""
|
|
669
|
+
stats = await plugin_registry.get_statistics(session)
|
|
670
|
+
return stats.get("validator_categories", [])
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
@router.get("/validators/custom/template")
|
|
674
|
+
async def get_validator_template() -> dict[str, str]:
|
|
675
|
+
"""Get a template for creating custom validators.
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
Dictionary with template code.
|
|
679
|
+
"""
|
|
680
|
+
executor = CustomValidatorExecutor()
|
|
681
|
+
return {"template": executor.get_validator_template()}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@router.get("/validators/custom/{validator_id}", response_model=CustomValidatorResponse)
|
|
685
|
+
async def get_custom_validator(
|
|
686
|
+
session: SessionDep,
|
|
687
|
+
validator_id: str,
|
|
688
|
+
) -> CustomValidatorResponse:
|
|
689
|
+
"""Get a custom validator by ID.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
session: Database session.
|
|
693
|
+
validator_id: Validator ID.
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
Validator details.
|
|
697
|
+
"""
|
|
698
|
+
validator = await plugin_registry.get_validator(session, validator_id=validator_id)
|
|
699
|
+
if not validator:
|
|
700
|
+
raise HTTPException(
|
|
701
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
702
|
+
detail=f"Validator {validator_id} not found",
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
return CustomValidatorResponse.from_model(validator)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@router.post(
|
|
709
|
+
"/validators/custom",
|
|
710
|
+
response_model=CustomValidatorResponse,
|
|
711
|
+
status_code=status.HTTP_201_CREATED,
|
|
712
|
+
)
|
|
713
|
+
async def create_custom_validator(
|
|
714
|
+
session: SessionDep,
|
|
715
|
+
request: CustomValidatorCreate,
|
|
716
|
+
) -> CustomValidatorResponse:
|
|
717
|
+
"""Create a custom validator.
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
session: Database session.
|
|
721
|
+
request: Validator creation request.
|
|
722
|
+
|
|
723
|
+
Returns:
|
|
724
|
+
Created validator.
|
|
725
|
+
"""
|
|
726
|
+
# Validate code first
|
|
727
|
+
executor = CustomValidatorExecutor()
|
|
728
|
+
is_valid, issues = executor.validate_validator_code(request.code)
|
|
729
|
+
if not is_valid:
|
|
730
|
+
raise HTTPException(
|
|
731
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
732
|
+
detail=f"Invalid validator code: {'; '.join(issues)}",
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
try:
|
|
736
|
+
validator = await plugin_registry.register_validator(
|
|
737
|
+
session=session,
|
|
738
|
+
name=request.name,
|
|
739
|
+
display_name=request.display_name,
|
|
740
|
+
description=request.description,
|
|
741
|
+
category=request.category,
|
|
742
|
+
code=request.code,
|
|
743
|
+
plugin_id=request.plugin_id,
|
|
744
|
+
severity=request.severity,
|
|
745
|
+
tags=request.tags,
|
|
746
|
+
parameters=[p.model_dump() for p in request.parameters],
|
|
747
|
+
test_cases=request.test_cases,
|
|
748
|
+
)
|
|
749
|
+
await session.commit()
|
|
750
|
+
return CustomValidatorResponse.from_model(validator)
|
|
751
|
+
except ValueError as e:
|
|
752
|
+
raise HTTPException(
|
|
753
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
754
|
+
detail=str(e),
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
@router.patch("/validators/custom/{validator_id}", response_model=CustomValidatorResponse)
|
|
759
|
+
async def update_custom_validator(
|
|
760
|
+
session: SessionDep,
|
|
761
|
+
validator_id: str,
|
|
762
|
+
request: CustomValidatorUpdate,
|
|
763
|
+
) -> CustomValidatorResponse:
|
|
764
|
+
"""Update a custom validator.
|
|
765
|
+
|
|
766
|
+
Args:
|
|
767
|
+
session: Database session.
|
|
768
|
+
validator_id: Validator ID.
|
|
769
|
+
request: Update request.
|
|
770
|
+
|
|
771
|
+
Returns:
|
|
772
|
+
Updated validator.
|
|
773
|
+
"""
|
|
774
|
+
# Validate code if provided
|
|
775
|
+
if request.code:
|
|
776
|
+
executor = CustomValidatorExecutor()
|
|
777
|
+
is_valid, issues = executor.validate_validator_code(request.code)
|
|
778
|
+
if not is_valid:
|
|
779
|
+
raise HTTPException(
|
|
780
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
781
|
+
detail=f"Invalid validator code: {'; '.join(issues)}",
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
try:
|
|
785
|
+
updates = request.model_dump(exclude_unset=True)
|
|
786
|
+
if "parameters" in updates and updates["parameters"]:
|
|
787
|
+
updates["parameters"] = [p.model_dump() if hasattr(p, "model_dump") else p for p in updates["parameters"]]
|
|
788
|
+
|
|
789
|
+
validator = await plugin_registry.update_validator(
|
|
790
|
+
session=session,
|
|
791
|
+
validator_id=validator_id,
|
|
792
|
+
**updates,
|
|
793
|
+
)
|
|
794
|
+
await session.commit()
|
|
795
|
+
return CustomValidatorResponse.from_model(validator)
|
|
796
|
+
except ValueError as e:
|
|
797
|
+
raise HTTPException(
|
|
798
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
799
|
+
detail=str(e),
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@router.delete("/validators/custom/{validator_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
804
|
+
async def delete_custom_validator(
|
|
805
|
+
session: SessionDep,
|
|
806
|
+
validator_id: str,
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Delete a custom validator.
|
|
809
|
+
|
|
810
|
+
Args:
|
|
811
|
+
session: Database session.
|
|
812
|
+
validator_id: Validator ID.
|
|
813
|
+
"""
|
|
814
|
+
try:
|
|
815
|
+
await plugin_registry.delete_validator(session, validator_id)
|
|
816
|
+
await session.commit()
|
|
817
|
+
except ValueError as e:
|
|
818
|
+
raise HTTPException(
|
|
819
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
820
|
+
detail=str(e),
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
@router.post("/validators/custom/test", response_model=ValidatorTestResponse)
|
|
825
|
+
async def test_custom_validator(request: ValidatorTestRequest) -> ValidatorTestResponse:
|
|
826
|
+
"""Test a custom validator without saving.
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
request: Test request with code and test data.
|
|
830
|
+
|
|
831
|
+
Returns:
|
|
832
|
+
Test results.
|
|
833
|
+
"""
|
|
834
|
+
executor = CustomValidatorExecutor()
|
|
835
|
+
result = await executor.test_validator(
|
|
836
|
+
code=request.code,
|
|
837
|
+
parameters=[p.model_dump() for p in request.parameters],
|
|
838
|
+
test_data=request.test_data,
|
|
839
|
+
param_values=request.param_values,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
return ValidatorTestResponse(
|
|
843
|
+
success=result["success"],
|
|
844
|
+
passed=result.get("passed"),
|
|
845
|
+
execution_time_ms=result["execution_time_ms"],
|
|
846
|
+
result=result.get("result"),
|
|
847
|
+
error=result.get("error"),
|
|
848
|
+
warnings=result.get("warnings", []),
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
# =============================================================================
|
|
853
|
+
# Custom Reporter Endpoints
|
|
854
|
+
# =============================================================================
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
@router.get("/reporters/custom", response_model=CustomReporterListResponse)
|
|
858
|
+
async def list_custom_reporters(
|
|
859
|
+
session: SessionDep,
|
|
860
|
+
plugin_id: str | None = None,
|
|
861
|
+
enabled_only: bool = False,
|
|
862
|
+
search: str | None = None,
|
|
863
|
+
offset: int = Query(default=0, ge=0),
|
|
864
|
+
limit: int = Query(default=20, ge=1, le=100),
|
|
865
|
+
) -> CustomReporterListResponse:
|
|
866
|
+
"""List custom reporters.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
session: Database session.
|
|
870
|
+
plugin_id: Filter by plugin.
|
|
871
|
+
enabled_only: Only return enabled reporters.
|
|
872
|
+
search: Search query.
|
|
873
|
+
offset: Pagination offset.
|
|
874
|
+
limit: Pagination limit.
|
|
875
|
+
|
|
876
|
+
Returns:
|
|
877
|
+
List of custom reporters.
|
|
878
|
+
"""
|
|
879
|
+
reporters, total = await plugin_registry.list_reporters(
|
|
880
|
+
session=session,
|
|
881
|
+
plugin_id=plugin_id,
|
|
882
|
+
enabled_only=enabled_only,
|
|
883
|
+
search=search,
|
|
884
|
+
offset=offset,
|
|
885
|
+
limit=limit,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
return CustomReporterListResponse(
|
|
889
|
+
data=[CustomReporterResponse.from_model(r) for r in reporters],
|
|
890
|
+
total=total,
|
|
891
|
+
offset=offset,
|
|
892
|
+
limit=limit,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
@router.get("/reporters/custom/templates")
|
|
897
|
+
async def get_reporter_templates() -> dict[str, str]:
|
|
898
|
+
"""Get templates for creating custom reporters.
|
|
899
|
+
|
|
900
|
+
Returns:
|
|
901
|
+
Dictionary with code and Jinja2 templates.
|
|
902
|
+
"""
|
|
903
|
+
executor = CustomReporterExecutor()
|
|
904
|
+
return {
|
|
905
|
+
"code_template": executor.get_reporter_template(),
|
|
906
|
+
"jinja2_template": executor.get_jinja2_template(),
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
@router.get("/reporters/custom/{reporter_id}", response_model=CustomReporterResponse)
|
|
911
|
+
async def get_custom_reporter(
|
|
912
|
+
session: SessionDep,
|
|
913
|
+
reporter_id: str,
|
|
914
|
+
) -> CustomReporterResponse:
|
|
915
|
+
"""Get a custom reporter by ID.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
session: Database session.
|
|
919
|
+
reporter_id: Reporter ID.
|
|
920
|
+
|
|
921
|
+
Returns:
|
|
922
|
+
Reporter details.
|
|
923
|
+
"""
|
|
924
|
+
reporter = await plugin_registry.get_reporter(session, reporter_id=reporter_id)
|
|
925
|
+
if not reporter:
|
|
926
|
+
raise HTTPException(
|
|
927
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
928
|
+
detail=f"Reporter {reporter_id} not found",
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
return CustomReporterResponse.from_model(reporter)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
@router.post(
|
|
935
|
+
"/reporters/custom",
|
|
936
|
+
response_model=CustomReporterResponse,
|
|
937
|
+
status_code=status.HTTP_201_CREATED,
|
|
938
|
+
)
|
|
939
|
+
async def create_custom_reporter(
|
|
940
|
+
session: SessionDep,
|
|
941
|
+
request: CustomReporterCreate,
|
|
942
|
+
) -> CustomReporterResponse:
|
|
943
|
+
"""Create a custom reporter.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
session: Database session.
|
|
947
|
+
request: Reporter creation request.
|
|
948
|
+
|
|
949
|
+
Returns:
|
|
950
|
+
Created reporter.
|
|
951
|
+
"""
|
|
952
|
+
# Validate code or template
|
|
953
|
+
executor = CustomReporterExecutor()
|
|
954
|
+
if request.code:
|
|
955
|
+
is_valid, issues = executor.validate_reporter_code(request.code)
|
|
956
|
+
if not is_valid:
|
|
957
|
+
raise HTTPException(
|
|
958
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
959
|
+
detail=f"Invalid reporter code: {'; '.join(issues)}",
|
|
960
|
+
)
|
|
961
|
+
if request.template:
|
|
962
|
+
is_valid, issues = executor.validate_template(request.template)
|
|
963
|
+
if not is_valid:
|
|
964
|
+
raise HTTPException(
|
|
965
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
966
|
+
detail=f"Invalid template: {'; '.join(issues)}",
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
try:
|
|
970
|
+
reporter = await plugin_registry.register_reporter(
|
|
971
|
+
session=session,
|
|
972
|
+
name=request.name,
|
|
973
|
+
display_name=request.display_name,
|
|
974
|
+
description=request.description,
|
|
975
|
+
plugin_id=request.plugin_id,
|
|
976
|
+
output_formats=[f.value for f in request.output_formats],
|
|
977
|
+
config_fields=[f.model_dump() for f in request.config_fields],
|
|
978
|
+
template=request.template,
|
|
979
|
+
code=request.code,
|
|
980
|
+
preview_image_url=request.preview_image_url,
|
|
981
|
+
)
|
|
982
|
+
await session.commit()
|
|
983
|
+
return CustomReporterResponse.from_model(reporter)
|
|
984
|
+
except ValueError as e:
|
|
985
|
+
raise HTTPException(
|
|
986
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
987
|
+
detail=str(e),
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
@router.patch("/reporters/custom/{reporter_id}", response_model=CustomReporterResponse)
|
|
992
|
+
async def update_custom_reporter(
|
|
993
|
+
session: SessionDep,
|
|
994
|
+
reporter_id: str,
|
|
995
|
+
request: CustomReporterUpdate,
|
|
996
|
+
) -> CustomReporterResponse:
|
|
997
|
+
"""Update a custom reporter.
|
|
998
|
+
|
|
999
|
+
Args:
|
|
1000
|
+
session: Database session.
|
|
1001
|
+
reporter_id: Reporter ID.
|
|
1002
|
+
request: Update request.
|
|
1003
|
+
|
|
1004
|
+
Returns:
|
|
1005
|
+
Updated reporter.
|
|
1006
|
+
"""
|
|
1007
|
+
executor = CustomReporterExecutor()
|
|
1008
|
+
|
|
1009
|
+
# Validate code or template if provided
|
|
1010
|
+
if request.code:
|
|
1011
|
+
is_valid, issues = executor.validate_reporter_code(request.code)
|
|
1012
|
+
if not is_valid:
|
|
1013
|
+
raise HTTPException(
|
|
1014
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1015
|
+
detail=f"Invalid reporter code: {'; '.join(issues)}",
|
|
1016
|
+
)
|
|
1017
|
+
if request.template:
|
|
1018
|
+
is_valid, issues = executor.validate_template(request.template)
|
|
1019
|
+
if not is_valid:
|
|
1020
|
+
raise HTTPException(
|
|
1021
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1022
|
+
detail=f"Invalid template: {'; '.join(issues)}",
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
try:
|
|
1026
|
+
updates = request.model_dump(exclude_unset=True)
|
|
1027
|
+
if "output_formats" in updates and updates["output_formats"]:
|
|
1028
|
+
updates["output_formats"] = [f.value if hasattr(f, "value") else f for f in updates["output_formats"]]
|
|
1029
|
+
if "config_fields" in updates and updates["config_fields"]:
|
|
1030
|
+
updates["config_fields"] = [f.model_dump() if hasattr(f, "model_dump") else f for f in updates["config_fields"]]
|
|
1031
|
+
|
|
1032
|
+
reporter = await plugin_registry.update_reporter(
|
|
1033
|
+
session=session,
|
|
1034
|
+
reporter_id=reporter_id,
|
|
1035
|
+
**updates,
|
|
1036
|
+
)
|
|
1037
|
+
await session.commit()
|
|
1038
|
+
return CustomReporterResponse.from_model(reporter)
|
|
1039
|
+
except ValueError as e:
|
|
1040
|
+
raise HTTPException(
|
|
1041
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1042
|
+
detail=str(e),
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
@router.delete("/reporters/custom/{reporter_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
1047
|
+
async def delete_custom_reporter(
|
|
1048
|
+
session: SessionDep,
|
|
1049
|
+
reporter_id: str,
|
|
1050
|
+
) -> None:
|
|
1051
|
+
"""Delete a custom reporter.
|
|
1052
|
+
|
|
1053
|
+
Args:
|
|
1054
|
+
session: Database session.
|
|
1055
|
+
reporter_id: Reporter ID.
|
|
1056
|
+
"""
|
|
1057
|
+
try:
|
|
1058
|
+
await plugin_registry.delete_reporter(session, reporter_id)
|
|
1059
|
+
await session.commit()
|
|
1060
|
+
except ValueError as e:
|
|
1061
|
+
raise HTTPException(
|
|
1062
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1063
|
+
detail=str(e),
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
@router.post("/reporters/custom/preview", response_model=ReporterGenerateResponse)
|
|
1068
|
+
async def preview_custom_reporter(
|
|
1069
|
+
template: str | None = None,
|
|
1070
|
+
code: str | None = None,
|
|
1071
|
+
sample_data: dict[str, Any] | None = None,
|
|
1072
|
+
config: dict[str, Any] | None = None,
|
|
1073
|
+
format: str = "html",
|
|
1074
|
+
) -> ReporterGenerateResponse:
|
|
1075
|
+
"""Preview a custom reporter without saving.
|
|
1076
|
+
|
|
1077
|
+
Args:
|
|
1078
|
+
template: Jinja2 template.
|
|
1079
|
+
code: Python code.
|
|
1080
|
+
sample_data: Sample data.
|
|
1081
|
+
config: Reporter configuration.
|
|
1082
|
+
format: Output format.
|
|
1083
|
+
|
|
1084
|
+
Returns:
|
|
1085
|
+
Preview result.
|
|
1086
|
+
"""
|
|
1087
|
+
executor = CustomReporterExecutor()
|
|
1088
|
+
result = await executor.preview_report(
|
|
1089
|
+
template=template,
|
|
1090
|
+
code=code,
|
|
1091
|
+
sample_data=sample_data,
|
|
1092
|
+
config=config,
|
|
1093
|
+
format=format,
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
return ReporterGenerateResponse(
|
|
1097
|
+
success=result.success,
|
|
1098
|
+
preview_html=result.content if result.success else None,
|
|
1099
|
+
error=result.error,
|
|
1100
|
+
generation_time_ms=result.execution_time_ms,
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
@router.post("/reporters/custom/{reporter_id}/generate", response_model=ReporterGenerateResponse)
|
|
1105
|
+
async def generate_report(
|
|
1106
|
+
session: SessionDep,
|
|
1107
|
+
reporter_id: str,
|
|
1108
|
+
request: ReporterGenerateRequest,
|
|
1109
|
+
) -> ReporterGenerateResponse:
|
|
1110
|
+
"""Generate a report using a custom reporter.
|
|
1111
|
+
|
|
1112
|
+
Supports two modes:
|
|
1113
|
+
1. Provide validation_id to auto-fetch validation data
|
|
1114
|
+
2. Provide data directly for custom report generation
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
session: Database session.
|
|
1118
|
+
reporter_id: Reporter ID.
|
|
1119
|
+
request: Generation request with validation_id or data.
|
|
1120
|
+
|
|
1121
|
+
Returns:
|
|
1122
|
+
Generation result with content or error.
|
|
1123
|
+
"""
|
|
1124
|
+
reporter = await plugin_registry.get_reporter(session, reporter_id=reporter_id)
|
|
1125
|
+
if not reporter:
|
|
1126
|
+
raise HTTPException(
|
|
1127
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1128
|
+
detail=f"Reporter {reporter_id} not found",
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
# Determine data source: validation_id takes precedence
|
|
1132
|
+
report_data: dict[str, Any] = {}
|
|
1133
|
+
metadata: dict[str, Any] = {}
|
|
1134
|
+
source_id: str | None = None
|
|
1135
|
+
|
|
1136
|
+
if request.validation_id:
|
|
1137
|
+
# Fetch validation data from database
|
|
1138
|
+
from sqlalchemy import select
|
|
1139
|
+
|
|
1140
|
+
stmt = select(Validation).where(Validation.id == request.validation_id)
|
|
1141
|
+
result = await session.execute(stmt)
|
|
1142
|
+
validation = result.scalar_one_or_none()
|
|
1143
|
+
|
|
1144
|
+
if not validation:
|
|
1145
|
+
raise HTTPException(
|
|
1146
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1147
|
+
detail=f"Validation {request.validation_id} not found",
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
# Build report data from validation
|
|
1151
|
+
report_data = {
|
|
1152
|
+
"validation_id": str(validation.id),
|
|
1153
|
+
"source_id": str(validation.source_id) if validation.source_id else None,
|
|
1154
|
+
"source_name": validation.source_name or "Unknown Source",
|
|
1155
|
+
"status": validation.status or "unknown",
|
|
1156
|
+
"passed": validation.passed,
|
|
1157
|
+
"started_at": validation.started_at.isoformat() if validation.started_at else None,
|
|
1158
|
+
"completed_at": validation.completed_at.isoformat() if validation.completed_at else None,
|
|
1159
|
+
"duration_ms": validation.duration_ms,
|
|
1160
|
+
"row_count": validation.row_count,
|
|
1161
|
+
"column_count": validation.column_count,
|
|
1162
|
+
"error_message": validation.error_message,
|
|
1163
|
+
"results": validation.results or [],
|
|
1164
|
+
"summary": validation.summary or {},
|
|
1165
|
+
"issues": validation.results or [],
|
|
1166
|
+
}
|
|
1167
|
+
metadata = {
|
|
1168
|
+
"generated_at": __import__("datetime").datetime.utcnow().isoformat(),
|
|
1169
|
+
"validation_id": str(validation.id),
|
|
1170
|
+
"source_name": validation.source_name or "Unknown Source",
|
|
1171
|
+
}
|
|
1172
|
+
source_id = str(validation.source_id) if validation.source_id else None
|
|
1173
|
+
|
|
1174
|
+
elif request.data:
|
|
1175
|
+
report_data = request.data
|
|
1176
|
+
metadata = {
|
|
1177
|
+
"generated_at": __import__("datetime").datetime.utcnow().isoformat(),
|
|
1178
|
+
}
|
|
1179
|
+
else:
|
|
1180
|
+
raise HTTPException(
|
|
1181
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1182
|
+
detail="Either validation_id or data must be provided",
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
executor = CustomReporterExecutor()
|
|
1186
|
+
context = ReportContext(
|
|
1187
|
+
data=report_data,
|
|
1188
|
+
config=request.config,
|
|
1189
|
+
format=request.output_format.value,
|
|
1190
|
+
metadata=metadata,
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
result = await executor.execute(
|
|
1194
|
+
reporter=reporter,
|
|
1195
|
+
context=context,
|
|
1196
|
+
session=session,
|
|
1197
|
+
source_id=source_id,
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
await session.commit()
|
|
1201
|
+
|
|
1202
|
+
return ReporterGenerateResponse(
|
|
1203
|
+
success=result.success,
|
|
1204
|
+
preview_html=result.content if result.success else None,
|
|
1205
|
+
error=result.error,
|
|
1206
|
+
generation_time_ms=result.execution_time_ms,
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
@router.get("/reporters/custom/{reporter_id}/download")
|
|
1211
|
+
async def download_custom_report(
|
|
1212
|
+
session: SessionDep,
|
|
1213
|
+
reporter_id: str,
|
|
1214
|
+
validation_id: str = Query(..., description="Validation ID to generate report from"),
|
|
1215
|
+
output_format: str = Query("html", description="Output format (html, json, csv, markdown, pdf)"),
|
|
1216
|
+
config: str | None = Query(None, description="JSON-encoded reporter configuration"),
|
|
1217
|
+
) -> Any:
|
|
1218
|
+
"""Download a report generated by a custom reporter.
|
|
1219
|
+
|
|
1220
|
+
This endpoint generates the report and returns it as a downloadable file.
|
|
1221
|
+
|
|
1222
|
+
Args:
|
|
1223
|
+
session: Database session.
|
|
1224
|
+
reporter_id: Reporter ID.
|
|
1225
|
+
validation_id: Validation ID to generate report from.
|
|
1226
|
+
output_format: Desired output format.
|
|
1227
|
+
config: Optional JSON-encoded configuration.
|
|
1228
|
+
|
|
1229
|
+
Returns:
|
|
1230
|
+
StreamingResponse with the generated report file.
|
|
1231
|
+
"""
|
|
1232
|
+
from datetime import datetime
|
|
1233
|
+
import json as json_module
|
|
1234
|
+
|
|
1235
|
+
from fastapi.responses import StreamingResponse
|
|
1236
|
+
|
|
1237
|
+
reporter = await plugin_registry.get_reporter(session, reporter_id=reporter_id)
|
|
1238
|
+
if not reporter:
|
|
1239
|
+
raise HTTPException(
|
|
1240
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1241
|
+
detail=f"Reporter {reporter_id} not found",
|
|
1242
|
+
)
|
|
1243
|
+
|
|
1244
|
+
# Fetch validation data
|
|
1245
|
+
from sqlalchemy import select
|
|
1246
|
+
|
|
1247
|
+
stmt = select(Validation).where(Validation.id == validation_id)
|
|
1248
|
+
result = await session.execute(stmt)
|
|
1249
|
+
validation = result.scalar_one_or_none()
|
|
1250
|
+
|
|
1251
|
+
if not validation:
|
|
1252
|
+
raise HTTPException(
|
|
1253
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1254
|
+
detail=f"Validation {validation_id} not found",
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
# Parse config if provided
|
|
1258
|
+
reporter_config: dict[str, Any] = {}
|
|
1259
|
+
if config:
|
|
1260
|
+
try:
|
|
1261
|
+
reporter_config = json_module.loads(config)
|
|
1262
|
+
except json_module.JSONDecodeError:
|
|
1263
|
+
raise HTTPException(
|
|
1264
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1265
|
+
detail="Invalid config JSON",
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
# Build report data from validation
|
|
1269
|
+
report_data = {
|
|
1270
|
+
"validation_id": str(validation.id),
|
|
1271
|
+
"source_id": str(validation.source_id) if validation.source_id else None,
|
|
1272
|
+
"source_name": validation.source_name or "Unknown Source",
|
|
1273
|
+
"status": validation.status or "unknown",
|
|
1274
|
+
"passed": validation.passed,
|
|
1275
|
+
"started_at": validation.started_at.isoformat() if validation.started_at else None,
|
|
1276
|
+
"completed_at": validation.completed_at.isoformat() if validation.completed_at else None,
|
|
1277
|
+
"duration_ms": validation.duration_ms,
|
|
1278
|
+
"row_count": validation.row_count,
|
|
1279
|
+
"column_count": validation.column_count,
|
|
1280
|
+
"error_message": validation.error_message,
|
|
1281
|
+
"results": validation.results or [],
|
|
1282
|
+
"summary": validation.summary or {},
|
|
1283
|
+
"issues": validation.results or [],
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
metadata = {
|
|
1287
|
+
"generated_at": datetime.utcnow().isoformat(),
|
|
1288
|
+
"validation_id": str(validation.id),
|
|
1289
|
+
"source_name": validation.source_name or "Unknown Source",
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
executor = CustomReporterExecutor()
|
|
1293
|
+
context = ReportContext(
|
|
1294
|
+
data=report_data,
|
|
1295
|
+
config=reporter_config,
|
|
1296
|
+
format=output_format,
|
|
1297
|
+
metadata=metadata,
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
exec_result = await executor.execute(
|
|
1301
|
+
reporter=reporter,
|
|
1302
|
+
context=context,
|
|
1303
|
+
session=session,
|
|
1304
|
+
source_id=str(validation.source_id) if validation.source_id else None,
|
|
1305
|
+
)
|
|
1306
|
+
|
|
1307
|
+
await session.commit()
|
|
1308
|
+
|
|
1309
|
+
if not exec_result.success:
|
|
1310
|
+
raise HTTPException(
|
|
1311
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1312
|
+
detail=f"Report generation failed: {exec_result.error}",
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
# Generate filename
|
|
1316
|
+
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
1317
|
+
filename = f"custom_report_{reporter.name}_{timestamp}.{_get_extension(output_format)}"
|
|
1318
|
+
|
|
1319
|
+
# Return as streaming response
|
|
1320
|
+
content = exec_result.content
|
|
1321
|
+
if isinstance(content, str):
|
|
1322
|
+
content = content.encode("utf-8")
|
|
1323
|
+
|
|
1324
|
+
return StreamingResponse(
|
|
1325
|
+
iter([content]),
|
|
1326
|
+
media_type=exec_result.content_type,
|
|
1327
|
+
headers={
|
|
1328
|
+
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
1329
|
+
"Content-Length": str(len(content)),
|
|
1330
|
+
},
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _get_extension(format_type: str) -> str:
|
|
1335
|
+
"""Get file extension for format type."""
|
|
1336
|
+
extensions = {
|
|
1337
|
+
"html": "html",
|
|
1338
|
+
"json": "json",
|
|
1339
|
+
"csv": "csv",
|
|
1340
|
+
"markdown": "md",
|
|
1341
|
+
"pdf": "pdf",
|
|
1342
|
+
"xml": "xml",
|
|
1343
|
+
"junit": "xml",
|
|
1344
|
+
}
|
|
1345
|
+
return extensions.get(format_type.lower(), "txt")
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
# =============================================================================
|
|
1349
|
+
# Plugin Lifecycle Endpoints
|
|
1350
|
+
# =============================================================================
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
@router.get("/plugins/{plugin_id}/lifecycle", response_model=PluginLifecycleResponse)
|
|
1354
|
+
async def get_plugin_lifecycle(
|
|
1355
|
+
session: SessionDep,
|
|
1356
|
+
plugin_id: str,
|
|
1357
|
+
) -> PluginLifecycleResponse:
|
|
1358
|
+
"""Get plugin lifecycle status.
|
|
1359
|
+
|
|
1360
|
+
Args:
|
|
1361
|
+
session: Database session.
|
|
1362
|
+
plugin_id: Plugin ID.
|
|
1363
|
+
|
|
1364
|
+
Returns:
|
|
1365
|
+
Plugin lifecycle status.
|
|
1366
|
+
"""
|
|
1367
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
1368
|
+
if not plugin:
|
|
1369
|
+
raise HTTPException(
|
|
1370
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1371
|
+
detail=f"Plugin {plugin_id} not found",
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
# Map DB status to lifecycle state
|
|
1375
|
+
state_map = {
|
|
1376
|
+
DBPluginStatus.available: PluginState.DISCOVERED,
|
|
1377
|
+
DBPluginStatus.installed: PluginState.LOADED,
|
|
1378
|
+
DBPluginStatus.enabled: PluginState.ACTIVE,
|
|
1379
|
+
DBPluginStatus.disabled: PluginState.LOADED,
|
|
1380
|
+
DBPluginStatus.error: PluginState.FAILED,
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
current_state = state_map.get(plugin.status, PluginState.DISCOVERED)
|
|
1384
|
+
|
|
1385
|
+
return PluginLifecycleResponse(
|
|
1386
|
+
plugin_id=plugin_id,
|
|
1387
|
+
current_state=current_state,
|
|
1388
|
+
can_activate=current_state == PluginState.LOADED,
|
|
1389
|
+
can_deactivate=current_state == PluginState.ACTIVE,
|
|
1390
|
+
can_reload=current_state in {PluginState.ACTIVE, PluginState.LOADED},
|
|
1391
|
+
can_upgrade=current_state in {PluginState.ACTIVE, PluginState.LOADED},
|
|
1392
|
+
recent_events=[],
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
@router.post("/plugins/{plugin_id}/transition", response_model=PluginTransitionResponse)
|
|
1397
|
+
async def transition_plugin_state(
|
|
1398
|
+
session: SessionDep,
|
|
1399
|
+
plugin_id: str,
|
|
1400
|
+
request: PluginTransitionRequest,
|
|
1401
|
+
) -> PluginTransitionResponse:
|
|
1402
|
+
"""Transition plugin to a new state.
|
|
1403
|
+
|
|
1404
|
+
Args:
|
|
1405
|
+
session: Database session.
|
|
1406
|
+
plugin_id: Plugin ID.
|
|
1407
|
+
request: Transition request.
|
|
1408
|
+
|
|
1409
|
+
Returns:
|
|
1410
|
+
Transition result.
|
|
1411
|
+
"""
|
|
1412
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
1413
|
+
if not plugin:
|
|
1414
|
+
raise HTTPException(
|
|
1415
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1416
|
+
detail=f"Plugin {plugin_id} not found",
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
# Map current DB status to lifecycle state
|
|
1420
|
+
state_map = {
|
|
1421
|
+
DBPluginStatus.available: PluginState.DISCOVERED,
|
|
1422
|
+
DBPluginStatus.installed: PluginState.LOADED,
|
|
1423
|
+
DBPluginStatus.enabled: PluginState.ACTIVE,
|
|
1424
|
+
DBPluginStatus.disabled: PluginState.LOADED,
|
|
1425
|
+
DBPluginStatus.error: PluginState.FAILED,
|
|
1426
|
+
}
|
|
1427
|
+
from_state = state_map.get(plugin.status, PluginState.DISCOVERED)
|
|
1428
|
+
|
|
1429
|
+
try:
|
|
1430
|
+
# Handle state transitions
|
|
1431
|
+
if request.target_state == PluginState.ACTIVE:
|
|
1432
|
+
plugin = await plugin_registry.enable_plugin(session, plugin_id)
|
|
1433
|
+
elif request.target_state == PluginState.LOADED:
|
|
1434
|
+
plugin = await plugin_registry.disable_plugin(session, plugin_id)
|
|
1435
|
+
elif request.target_state == PluginState.UNLOADED:
|
|
1436
|
+
await plugin_registry.uninstall_plugin(session, plugin_id)
|
|
1437
|
+
|
|
1438
|
+
await session.commit()
|
|
1439
|
+
|
|
1440
|
+
return PluginTransitionResponse(
|
|
1441
|
+
success=True,
|
|
1442
|
+
plugin_id=plugin_id,
|
|
1443
|
+
from_state=from_state,
|
|
1444
|
+
to_state=request.target_state,
|
|
1445
|
+
message=f"Plugin transitioned to {request.target_state.value}",
|
|
1446
|
+
)
|
|
1447
|
+
except ValueError as e:
|
|
1448
|
+
return PluginTransitionResponse(
|
|
1449
|
+
success=False,
|
|
1450
|
+
plugin_id=plugin_id,
|
|
1451
|
+
from_state=from_state,
|
|
1452
|
+
to_state=from_state,
|
|
1453
|
+
error=str(e),
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
# =============================================================================
|
|
1458
|
+
# Plugin Hot Reload Endpoints
|
|
1459
|
+
# =============================================================================
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
@router.get("/plugins/{plugin_id}/hot-reload", response_model=HotReloadStatus)
|
|
1463
|
+
async def get_hot_reload_status(
|
|
1464
|
+
session: SessionDep,
|
|
1465
|
+
plugin_id: str,
|
|
1466
|
+
) -> HotReloadStatus:
|
|
1467
|
+
"""Get hot reload status for a plugin.
|
|
1468
|
+
|
|
1469
|
+
Args:
|
|
1470
|
+
session: Database session.
|
|
1471
|
+
plugin_id: Plugin ID.
|
|
1472
|
+
|
|
1473
|
+
Returns:
|
|
1474
|
+
Hot reload status.
|
|
1475
|
+
"""
|
|
1476
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
1477
|
+
if not plugin:
|
|
1478
|
+
raise HTTPException(
|
|
1479
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1480
|
+
detail=f"Plugin {plugin_id} not found",
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
# Return default status (would be managed by HotReloadManager in production)
|
|
1484
|
+
from truthound_dashboard.schemas.plugins import ReloadStrategy
|
|
1485
|
+
|
|
1486
|
+
return HotReloadStatus(
|
|
1487
|
+
plugin_id=plugin_id,
|
|
1488
|
+
enabled=False,
|
|
1489
|
+
watching=False,
|
|
1490
|
+
strategy=ReloadStrategy.MANUAL,
|
|
1491
|
+
has_pending_reload=False,
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
@router.post("/plugins/{plugin_id}/hot-reload/configure", response_model=HotReloadStatus)
|
|
1496
|
+
async def configure_hot_reload(
|
|
1497
|
+
session: SessionDep,
|
|
1498
|
+
plugin_id: str,
|
|
1499
|
+
request: HotReloadConfigRequest,
|
|
1500
|
+
) -> HotReloadStatus:
|
|
1501
|
+
"""Configure hot reload for a plugin.
|
|
1502
|
+
|
|
1503
|
+
Args:
|
|
1504
|
+
session: Database session.
|
|
1505
|
+
plugin_id: Plugin ID.
|
|
1506
|
+
request: Hot reload configuration.
|
|
1507
|
+
|
|
1508
|
+
Returns:
|
|
1509
|
+
Updated hot reload status.
|
|
1510
|
+
"""
|
|
1511
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
1512
|
+
if not plugin:
|
|
1513
|
+
raise HTTPException(
|
|
1514
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1515
|
+
detail=f"Plugin {plugin_id} not found",
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
# In production, this would configure the HotReloadManager
|
|
1519
|
+
return HotReloadStatus(
|
|
1520
|
+
plugin_id=plugin_id,
|
|
1521
|
+
enabled=request.enabled,
|
|
1522
|
+
watching=request.enabled,
|
|
1523
|
+
strategy=request.strategy,
|
|
1524
|
+
has_pending_reload=False,
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
@router.post("/plugins/{plugin_id}/hot-reload/trigger", response_model=HotReloadResult)
|
|
1529
|
+
async def trigger_hot_reload(
|
|
1530
|
+
session: SessionDep,
|
|
1531
|
+
plugin_id: str,
|
|
1532
|
+
) -> HotReloadResult:
|
|
1533
|
+
"""Manually trigger a hot reload for a plugin.
|
|
1534
|
+
|
|
1535
|
+
Args:
|
|
1536
|
+
session: Database session.
|
|
1537
|
+
plugin_id: Plugin ID.
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
Hot reload result.
|
|
1541
|
+
"""
|
|
1542
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
1543
|
+
if not plugin:
|
|
1544
|
+
raise HTTPException(
|
|
1545
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1546
|
+
detail=f"Plugin {plugin_id} not found",
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
# In production, this would trigger the HotReloadManager
|
|
1550
|
+
import time
|
|
1551
|
+
|
|
1552
|
+
start = time.perf_counter()
|
|
1553
|
+
# Simulate reload
|
|
1554
|
+
duration = (time.perf_counter() - start) * 1000
|
|
1555
|
+
|
|
1556
|
+
return HotReloadResult(
|
|
1557
|
+
success=True,
|
|
1558
|
+
plugin_id=plugin_id,
|
|
1559
|
+
old_version=plugin.version,
|
|
1560
|
+
new_version=plugin.version,
|
|
1561
|
+
duration_ms=duration,
|
|
1562
|
+
changes=[],
|
|
1563
|
+
)
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
# =============================================================================
|
|
1567
|
+
# Plugin Dependency Endpoints
|
|
1568
|
+
# =============================================================================
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
@router.get("/plugins/{plugin_id}/dependencies", response_model=DependencyGraphResponse)
|
|
1572
|
+
async def get_plugin_dependencies(
|
|
1573
|
+
session: SessionDep,
|
|
1574
|
+
plugin_id: str,
|
|
1575
|
+
include_optional: bool = False,
|
|
1576
|
+
) -> DependencyGraphResponse:
|
|
1577
|
+
"""Get dependency graph for a plugin.
|
|
1578
|
+
|
|
1579
|
+
Args:
|
|
1580
|
+
session: Database session.
|
|
1581
|
+
plugin_id: Plugin ID.
|
|
1582
|
+
include_optional: Include optional dependencies.
|
|
1583
|
+
|
|
1584
|
+
Returns:
|
|
1585
|
+
Dependency graph.
|
|
1586
|
+
"""
|
|
1587
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
1588
|
+
if not plugin:
|
|
1589
|
+
raise HTTPException(
|
|
1590
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1591
|
+
detail=f"Plugin {plugin_id} not found",
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
from truthound_dashboard.schemas.plugins import (
|
|
1595
|
+
DependencyGraphNode,
|
|
1596
|
+
DependencyInfo,
|
|
1597
|
+
DependencyType,
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
# Build dependency graph from plugin dependencies
|
|
1601
|
+
nodes = []
|
|
1602
|
+
dependencies = plugin.dependencies or []
|
|
1603
|
+
|
|
1604
|
+
root_node = DependencyGraphNode(
|
|
1605
|
+
plugin_id=plugin_id,
|
|
1606
|
+
version=plugin.version,
|
|
1607
|
+
dependencies=[
|
|
1608
|
+
DependencyInfo(
|
|
1609
|
+
plugin_id=dep.get("plugin_id", ""),
|
|
1610
|
+
version_constraint=dep.get("version_constraint", "*"),
|
|
1611
|
+
dependency_type=DependencyType(dep.get("type", "required")),
|
|
1612
|
+
is_installed=False,
|
|
1613
|
+
is_satisfied=False,
|
|
1614
|
+
)
|
|
1615
|
+
for dep in dependencies
|
|
1616
|
+
if include_optional or not dep.get("optional", False)
|
|
1617
|
+
],
|
|
1618
|
+
dependents=[],
|
|
1619
|
+
depth=0,
|
|
1620
|
+
)
|
|
1621
|
+
nodes.append(root_node)
|
|
1622
|
+
|
|
1623
|
+
return DependencyGraphResponse(
|
|
1624
|
+
root_plugin_id=plugin_id,
|
|
1625
|
+
nodes=nodes,
|
|
1626
|
+
has_cycles=False,
|
|
1627
|
+
install_order=[plugin_id],
|
|
1628
|
+
total_dependencies=len(dependencies),
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
@router.post("/plugins/dependencies/resolve", response_model=DependencyResolutionResponse)
|
|
1633
|
+
async def resolve_dependencies(
|
|
1634
|
+
session: SessionDep,
|
|
1635
|
+
request: DependencyResolutionRequest,
|
|
1636
|
+
) -> DependencyResolutionResponse:
|
|
1637
|
+
"""Resolve dependencies for a set of plugins.
|
|
1638
|
+
|
|
1639
|
+
Args:
|
|
1640
|
+
session: Database session.
|
|
1641
|
+
request: Resolution request.
|
|
1642
|
+
|
|
1643
|
+
Returns:
|
|
1644
|
+
Resolution result.
|
|
1645
|
+
"""
|
|
1646
|
+
from truthound_dashboard.schemas.plugins import DependencyInfo, DependencyType
|
|
1647
|
+
|
|
1648
|
+
resolved = []
|
|
1649
|
+
unresolved = []
|
|
1650
|
+
install_order = []
|
|
1651
|
+
|
|
1652
|
+
for plugin_id in request.plugin_ids:
|
|
1653
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
1654
|
+
if plugin:
|
|
1655
|
+
install_order.append(plugin_id)
|
|
1656
|
+
for dep in plugin.dependencies or []:
|
|
1657
|
+
dep_info = DependencyInfo(
|
|
1658
|
+
plugin_id=dep.get("plugin_id", ""),
|
|
1659
|
+
version_constraint=dep.get("version_constraint", "*"),
|
|
1660
|
+
dependency_type=DependencyType(dep.get("type", "required")),
|
|
1661
|
+
is_installed=False,
|
|
1662
|
+
is_satisfied=False,
|
|
1663
|
+
)
|
|
1664
|
+
# Check if dependency is installed
|
|
1665
|
+
dep_plugin = await plugin_registry.get_plugin(
|
|
1666
|
+
session, plugin_id=dep.get("plugin_id", "")
|
|
1667
|
+
)
|
|
1668
|
+
if dep_plugin:
|
|
1669
|
+
dep_info.is_installed = True
|
|
1670
|
+
dep_info.resolved_version = dep_plugin.version
|
|
1671
|
+
dep_info.is_satisfied = True
|
|
1672
|
+
resolved.append(dep_info)
|
|
1673
|
+
else:
|
|
1674
|
+
unresolved.append(dep_info)
|
|
1675
|
+
else:
|
|
1676
|
+
unresolved.append(
|
|
1677
|
+
DependencyInfo(
|
|
1678
|
+
plugin_id=plugin_id,
|
|
1679
|
+
version_constraint="*",
|
|
1680
|
+
is_installed=False,
|
|
1681
|
+
is_satisfied=False,
|
|
1682
|
+
)
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
return DependencyResolutionResponse(
|
|
1686
|
+
success=len(unresolved) == 0,
|
|
1687
|
+
resolved=resolved,
|
|
1688
|
+
unresolved=unresolved,
|
|
1689
|
+
conflicts=[],
|
|
1690
|
+
install_order=install_order,
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
# =============================================================================
|
|
1695
|
+
# Plugin Security Endpoints
|
|
1696
|
+
# =============================================================================
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
@router.get("/plugins/security/trust-store", response_model=TrustStoreResponse)
|
|
1700
|
+
async def get_trust_store(session: SessionDep) -> TrustStoreResponse:
|
|
1701
|
+
"""Get the trust store containing trusted signers.
|
|
1702
|
+
|
|
1703
|
+
Args:
|
|
1704
|
+
session: Database session.
|
|
1705
|
+
|
|
1706
|
+
Returns:
|
|
1707
|
+
Trust store information.
|
|
1708
|
+
"""
|
|
1709
|
+
# In production, this would read from the actual TrustStore
|
|
1710
|
+
return TrustStoreResponse(
|
|
1711
|
+
signers=[],
|
|
1712
|
+
total_signers=0,
|
|
1713
|
+
)
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
@router.post("/plugins/security/trust-store/signers", response_model=TrustedSigner)
|
|
1717
|
+
async def add_trusted_signer(
|
|
1718
|
+
session: SessionDep,
|
|
1719
|
+
request: AddSignerRequest,
|
|
1720
|
+
) -> TrustedSigner:
|
|
1721
|
+
"""Add a trusted signer to the trust store.
|
|
1722
|
+
|
|
1723
|
+
Args:
|
|
1724
|
+
session: Database session.
|
|
1725
|
+
request: Signer information.
|
|
1726
|
+
|
|
1727
|
+
Returns:
|
|
1728
|
+
Added signer.
|
|
1729
|
+
"""
|
|
1730
|
+
from datetime import datetime
|
|
1731
|
+
|
|
1732
|
+
from truthound_dashboard.schemas.plugins import SecurityLevel
|
|
1733
|
+
|
|
1734
|
+
# In production, this would add to the actual TrustStore
|
|
1735
|
+
return TrustedSigner(
|
|
1736
|
+
signer_id=request.signer_id,
|
|
1737
|
+
name=request.name,
|
|
1738
|
+
public_key=request.public_key,
|
|
1739
|
+
algorithm=request.algorithm,
|
|
1740
|
+
added_at=datetime.utcnow(),
|
|
1741
|
+
expires_at=request.expires_at,
|
|
1742
|
+
is_active=True,
|
|
1743
|
+
trust_level=request.trust_level or SecurityLevel.VERIFIED,
|
|
1744
|
+
)
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
@router.delete("/plugins/security/trust-store/signers/{signer_id}")
|
|
1748
|
+
async def remove_trusted_signer(
|
|
1749
|
+
session: SessionDep,
|
|
1750
|
+
signer_id: str,
|
|
1751
|
+
) -> dict[str, bool]:
|
|
1752
|
+
"""Remove a trusted signer from the trust store.
|
|
1753
|
+
|
|
1754
|
+
Args:
|
|
1755
|
+
session: Database session.
|
|
1756
|
+
signer_id: Signer ID to remove.
|
|
1757
|
+
|
|
1758
|
+
Returns:
|
|
1759
|
+
Success status.
|
|
1760
|
+
"""
|
|
1761
|
+
# In production, this would remove from the actual TrustStore
|
|
1762
|
+
return {"success": True}
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
@router.get("/plugins/security/policy", response_model=SecurityPolicyConfig)
|
|
1766
|
+
async def get_security_policy(session: SessionDep) -> SecurityPolicyConfig:
|
|
1767
|
+
"""Get current security policy configuration.
|
|
1768
|
+
|
|
1769
|
+
Args:
|
|
1770
|
+
session: Database session.
|
|
1771
|
+
|
|
1772
|
+
Returns:
|
|
1773
|
+
Security policy configuration.
|
|
1774
|
+
"""
|
|
1775
|
+
from truthound_dashboard.schemas.plugins import (
|
|
1776
|
+
IsolationLevel,
|
|
1777
|
+
SecurityPolicyPreset,
|
|
1778
|
+
)
|
|
1779
|
+
|
|
1780
|
+
# Return default policy
|
|
1781
|
+
return SecurityPolicyConfig(
|
|
1782
|
+
preset=SecurityPolicyPreset.STANDARD,
|
|
1783
|
+
isolation_level=IsolationLevel.PROCESS,
|
|
1784
|
+
require_signature=True,
|
|
1785
|
+
min_signatures=1,
|
|
1786
|
+
memory_limit_mb=256,
|
|
1787
|
+
cpu_time_limit_sec=30,
|
|
1788
|
+
network_enabled=False,
|
|
1789
|
+
filesystem_read=False,
|
|
1790
|
+
filesystem_write=False,
|
|
1791
|
+
)
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
@router.put("/plugins/security/policy", response_model=SecurityPolicyConfig)
|
|
1795
|
+
async def update_security_policy(
|
|
1796
|
+
session: SessionDep,
|
|
1797
|
+
request: SecurityPolicyConfig,
|
|
1798
|
+
) -> SecurityPolicyConfig:
|
|
1799
|
+
"""Update security policy configuration.
|
|
1800
|
+
|
|
1801
|
+
Args:
|
|
1802
|
+
session: Database session.
|
|
1803
|
+
request: New policy configuration.
|
|
1804
|
+
|
|
1805
|
+
Returns:
|
|
1806
|
+
Updated policy configuration.
|
|
1807
|
+
"""
|
|
1808
|
+
# In production, this would persist the policy
|
|
1809
|
+
return request
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
@router.post("/plugins/{plugin_id}/security/analyze", response_model=ExtendedSecurityReport)
|
|
1813
|
+
async def analyze_plugin_security(
|
|
1814
|
+
session: SessionDep,
|
|
1815
|
+
plugin_id: str,
|
|
1816
|
+
request: SecurityAnalysisRequest | None = None,
|
|
1817
|
+
) -> ExtendedSecurityReport:
|
|
1818
|
+
"""Perform detailed security analysis on a plugin.
|
|
1819
|
+
|
|
1820
|
+
Args:
|
|
1821
|
+
session: Database session.
|
|
1822
|
+
plugin_id: Plugin ID.
|
|
1823
|
+
request: Analysis request.
|
|
1824
|
+
|
|
1825
|
+
Returns:
|
|
1826
|
+
Extended security report.
|
|
1827
|
+
"""
|
|
1828
|
+
from datetime import datetime
|
|
1829
|
+
|
|
1830
|
+
from truthound_dashboard.schemas.plugins import SecurityLevel
|
|
1831
|
+
|
|
1832
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
1833
|
+
if not plugin:
|
|
1834
|
+
raise HTTPException(
|
|
1835
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1836
|
+
detail=f"Plugin {plugin_id} not found",
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
# Perform security analysis
|
|
1840
|
+
code = request.code if request else None
|
|
1841
|
+
code_analysis = None
|
|
1842
|
+
|
|
1843
|
+
if code:
|
|
1844
|
+
code_analysis = CodeAnalysisResult(
|
|
1845
|
+
is_safe=True,
|
|
1846
|
+
issues=[],
|
|
1847
|
+
warnings=[],
|
|
1848
|
+
blocked_constructs=[],
|
|
1849
|
+
detected_imports=[],
|
|
1850
|
+
detected_permissions=[],
|
|
1851
|
+
complexity_score=0,
|
|
1852
|
+
)
|
|
1853
|
+
|
|
1854
|
+
return ExtendedSecurityReport(
|
|
1855
|
+
plugin_id=plugin_id,
|
|
1856
|
+
analyzed_at=datetime.utcnow(),
|
|
1857
|
+
risk_level=plugin.security_level or SecurityLevel.UNVERIFIED,
|
|
1858
|
+
issues=[],
|
|
1859
|
+
warnings=[],
|
|
1860
|
+
permissions_required=plugin.permissions or [],
|
|
1861
|
+
signature_valid=False,
|
|
1862
|
+
sandbox_compatible=True,
|
|
1863
|
+
code_analysis=code_analysis,
|
|
1864
|
+
signature_count=0,
|
|
1865
|
+
trust_level=plugin.security_level or SecurityLevel.UNVERIFIED,
|
|
1866
|
+
can_run_in_sandbox=True,
|
|
1867
|
+
code_hash="",
|
|
1868
|
+
recommendations=["Sign the plugin for production use"],
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
@router.post("/plugins/security/verify-signature", response_model=VerifySignatureResponse)
|
|
1873
|
+
async def verify_plugin_signature(
|
|
1874
|
+
session: SessionDep,
|
|
1875
|
+
request: VerifySignatureRequest,
|
|
1876
|
+
) -> VerifySignatureResponse:
|
|
1877
|
+
"""Verify a plugin signature.
|
|
1878
|
+
|
|
1879
|
+
Args:
|
|
1880
|
+
session: Database session.
|
|
1881
|
+
request: Verification request.
|
|
1882
|
+
|
|
1883
|
+
Returns:
|
|
1884
|
+
Verification result.
|
|
1885
|
+
"""
|
|
1886
|
+
# In production, this would use the actual verification chain
|
|
1887
|
+
return VerifySignatureResponse(
|
|
1888
|
+
is_valid=False,
|
|
1889
|
+
error="Signature verification not implemented",
|
|
1890
|
+
)
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
# =============================================================================
|
|
1894
|
+
# Plugin Hooks Endpoints
|
|
1895
|
+
# =============================================================================
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
@router.get("/plugins/hooks", response_model=HookListResponse)
|
|
1899
|
+
async def list_hooks(
|
|
1900
|
+
session: SessionDep,
|
|
1901
|
+
hook_type: HookType | None = None,
|
|
1902
|
+
plugin_id: str | None = None,
|
|
1903
|
+
) -> HookListResponse:
|
|
1904
|
+
"""List registered hooks.
|
|
1905
|
+
|
|
1906
|
+
Args:
|
|
1907
|
+
session: Database session.
|
|
1908
|
+
hook_type: Filter by hook type.
|
|
1909
|
+
plugin_id: Filter by plugin ID.
|
|
1910
|
+
|
|
1911
|
+
Returns:
|
|
1912
|
+
List of registered hooks.
|
|
1913
|
+
"""
|
|
1914
|
+
# In production, this would query the HookManager
|
|
1915
|
+
return HookListResponse(
|
|
1916
|
+
hooks=[],
|
|
1917
|
+
total=0,
|
|
1918
|
+
by_type={},
|
|
1919
|
+
)
|
|
1920
|
+
|
|
1921
|
+
|
|
1922
|
+
@router.post("/plugins/hooks", response_model=HookRegistration)
|
|
1923
|
+
async def register_hook(
|
|
1924
|
+
session: SessionDep,
|
|
1925
|
+
request: RegisterHookRequest,
|
|
1926
|
+
) -> HookRegistration:
|
|
1927
|
+
"""Register a new hook.
|
|
1928
|
+
|
|
1929
|
+
Args:
|
|
1930
|
+
session: Database session.
|
|
1931
|
+
request: Hook registration request.
|
|
1932
|
+
|
|
1933
|
+
Returns:
|
|
1934
|
+
Registered hook.
|
|
1935
|
+
"""
|
|
1936
|
+
import uuid
|
|
1937
|
+
|
|
1938
|
+
from truthound_dashboard.schemas.plugins import HookPriority
|
|
1939
|
+
|
|
1940
|
+
# In production, this would register with the HookManager
|
|
1941
|
+
return HookRegistration(
|
|
1942
|
+
id=str(uuid.uuid4()),
|
|
1943
|
+
hook_type=request.hook_type,
|
|
1944
|
+
plugin_id=request.plugin_id,
|
|
1945
|
+
function_name=request.function_name,
|
|
1946
|
+
priority=request.priority or HookPriority.NORMAL,
|
|
1947
|
+
is_async=False,
|
|
1948
|
+
is_enabled=True,
|
|
1949
|
+
description=request.description,
|
|
1950
|
+
)
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
@router.delete("/plugins/hooks/{hook_id}")
|
|
1954
|
+
async def unregister_hook(
|
|
1955
|
+
session: SessionDep,
|
|
1956
|
+
hook_id: str,
|
|
1957
|
+
) -> dict[str, bool]:
|
|
1958
|
+
"""Unregister a hook.
|
|
1959
|
+
|
|
1960
|
+
Args:
|
|
1961
|
+
session: Database session.
|
|
1962
|
+
hook_id: Hook ID to unregister.
|
|
1963
|
+
|
|
1964
|
+
Returns:
|
|
1965
|
+
Success status.
|
|
1966
|
+
"""
|
|
1967
|
+
# In production, this would unregister from the HookManager
|
|
1968
|
+
return {"success": True}
|
|
1969
|
+
|
|
1970
|
+
|
|
1971
|
+
@router.get("/plugins/hooks/types")
|
|
1972
|
+
async def list_hook_types() -> list[dict[str, str]]:
|
|
1973
|
+
"""List available hook types.
|
|
1974
|
+
|
|
1975
|
+
Returns:
|
|
1976
|
+
List of hook types with descriptions.
|
|
1977
|
+
"""
|
|
1978
|
+
return [
|
|
1979
|
+
{"type": "before_validation", "description": "Runs before validation starts"},
|
|
1980
|
+
{"type": "after_validation", "description": "Runs after validation completes"},
|
|
1981
|
+
{"type": "on_issue_found", "description": "Runs when a validation issue is found"},
|
|
1982
|
+
{"type": "before_profile", "description": "Runs before data profiling"},
|
|
1983
|
+
{"type": "after_profile", "description": "Runs after data profiling"},
|
|
1984
|
+
{"type": "before_compare", "description": "Runs before drift comparison"},
|
|
1985
|
+
{"type": "after_compare", "description": "Runs after drift comparison"},
|
|
1986
|
+
{"type": "on_plugin_load", "description": "Runs when a plugin is loaded"},
|
|
1987
|
+
{"type": "on_plugin_unload", "description": "Runs when a plugin is unloaded"},
|
|
1988
|
+
{"type": "on_plugin_error", "description": "Runs when a plugin error occurs"},
|
|
1989
|
+
{"type": "before_notification", "description": "Runs before sending notifications"},
|
|
1990
|
+
{"type": "after_notification", "description": "Runs after sending notifications"},
|
|
1991
|
+
{"type": "on_schedule_run", "description": "Runs when a schedule executes"},
|
|
1992
|
+
{"type": "on_data_source_connect", "description": "Runs when connecting to a data source"},
|
|
1993
|
+
{"type": "on_schema_change", "description": "Runs when schema changes are detected"},
|
|
1994
|
+
{"type": "custom", "description": "Custom hook type"},
|
|
1995
|
+
]
|
|
1996
|
+
|
|
1997
|
+
|
|
1998
|
+
# =============================================================================
|
|
1999
|
+
# Plugin Documentation Endpoints
|
|
2000
|
+
# =============================================================================
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
@router.get("/plugins/{plugin_id}/documentation", response_model=PluginDocumentation)
|
|
2004
|
+
async def get_plugin_documentation(
|
|
2005
|
+
session: SessionDep,
|
|
2006
|
+
plugin_id: str,
|
|
2007
|
+
) -> PluginDocumentation:
|
|
2008
|
+
"""Get documentation for a plugin.
|
|
2009
|
+
|
|
2010
|
+
Args:
|
|
2011
|
+
session: Database session.
|
|
2012
|
+
plugin_id: Plugin ID.
|
|
2013
|
+
|
|
2014
|
+
Returns:
|
|
2015
|
+
Plugin documentation.
|
|
2016
|
+
"""
|
|
2017
|
+
from datetime import datetime
|
|
2018
|
+
|
|
2019
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
2020
|
+
if not plugin:
|
|
2021
|
+
raise HTTPException(
|
|
2022
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
2023
|
+
detail=f"Plugin {plugin_id} not found",
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
return PluginDocumentation(
|
|
2027
|
+
plugin_id=plugin_id,
|
|
2028
|
+
plugin_name=plugin.name,
|
|
2029
|
+
version=plugin.version,
|
|
2030
|
+
modules=[],
|
|
2031
|
+
readme=plugin.readme,
|
|
2032
|
+
changelog=plugin.changelog,
|
|
2033
|
+
examples=[],
|
|
2034
|
+
generated_at=datetime.utcnow(),
|
|
2035
|
+
)
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
@router.post("/plugins/{plugin_id}/documentation/render", response_model=DocumentationRenderResponse)
|
|
2039
|
+
async def render_plugin_documentation(
|
|
2040
|
+
session: SessionDep,
|
|
2041
|
+
plugin_id: str,
|
|
2042
|
+
request: DocumentationRenderRequest,
|
|
2043
|
+
) -> DocumentationRenderResponse:
|
|
2044
|
+
"""Render plugin documentation in specified format.
|
|
2045
|
+
|
|
2046
|
+
Args:
|
|
2047
|
+
session: Database session.
|
|
2048
|
+
plugin_id: Plugin ID.
|
|
2049
|
+
request: Render request.
|
|
2050
|
+
|
|
2051
|
+
Returns:
|
|
2052
|
+
Rendered documentation.
|
|
2053
|
+
"""
|
|
2054
|
+
import time
|
|
2055
|
+
|
|
2056
|
+
plugin = await plugin_registry.get_plugin(session, plugin_id=plugin_id)
|
|
2057
|
+
if not plugin:
|
|
2058
|
+
raise HTTPException(
|
|
2059
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
2060
|
+
detail=f"Plugin {plugin_id} not found",
|
|
2061
|
+
)
|
|
2062
|
+
|
|
2063
|
+
start = time.perf_counter()
|
|
2064
|
+
|
|
2065
|
+
# Generate documentation based on format
|
|
2066
|
+
if request.format == "markdown":
|
|
2067
|
+
content = f"# {plugin.display_name}\n\n{plugin.description or ''}\n\n"
|
|
2068
|
+
if plugin.readme:
|
|
2069
|
+
content += plugin.readme
|
|
2070
|
+
elif request.format == "html":
|
|
2071
|
+
content = f"<h1>{plugin.display_name}</h1>\n<p>{plugin.description or ''}</p>\n"
|
|
2072
|
+
if plugin.readme:
|
|
2073
|
+
content += f"<div class='readme'>{plugin.readme}</div>"
|
|
2074
|
+
else: # json
|
|
2075
|
+
import json
|
|
2076
|
+
|
|
2077
|
+
content = json.dumps(
|
|
2078
|
+
{
|
|
2079
|
+
"name": plugin.name,
|
|
2080
|
+
"display_name": plugin.display_name,
|
|
2081
|
+
"description": plugin.description,
|
|
2082
|
+
"version": plugin.version,
|
|
2083
|
+
"readme": plugin.readme,
|
|
2084
|
+
"changelog": plugin.changelog,
|
|
2085
|
+
},
|
|
2086
|
+
indent=2,
|
|
2087
|
+
)
|
|
2088
|
+
|
|
2089
|
+
duration = (time.perf_counter() - start) * 1000
|
|
2090
|
+
|
|
2091
|
+
return DocumentationRenderResponse(
|
|
2092
|
+
plugin_id=plugin_id,
|
|
2093
|
+
format=request.format,
|
|
2094
|
+
content=content,
|
|
2095
|
+
generation_time_ms=duration,
|
|
2096
|
+
)
|