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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. truthound_dashboard/api/alerts.py +258 -0
  2. truthound_dashboard/api/anomaly.py +1302 -0
  3. truthound_dashboard/api/cross_alerts.py +352 -0
  4. truthound_dashboard/api/deps.py +143 -0
  5. truthound_dashboard/api/drift_monitor.py +540 -0
  6. truthound_dashboard/api/lineage.py +1151 -0
  7. truthound_dashboard/api/maintenance.py +363 -0
  8. truthound_dashboard/api/middleware.py +373 -1
  9. truthound_dashboard/api/model_monitoring.py +805 -0
  10. truthound_dashboard/api/notifications_advanced.py +2452 -0
  11. truthound_dashboard/api/plugins.py +2096 -0
  12. truthound_dashboard/api/profile.py +211 -14
  13. truthound_dashboard/api/reports.py +853 -0
  14. truthound_dashboard/api/router.py +147 -0
  15. truthound_dashboard/api/rule_suggestions.py +310 -0
  16. truthound_dashboard/api/schema_evolution.py +231 -0
  17. truthound_dashboard/api/sources.py +47 -3
  18. truthound_dashboard/api/triggers.py +190 -0
  19. truthound_dashboard/api/validations.py +13 -0
  20. truthound_dashboard/api/validators.py +333 -4
  21. truthound_dashboard/api/versioning.py +309 -0
  22. truthound_dashboard/api/websocket.py +301 -0
  23. truthound_dashboard/core/__init__.py +27 -0
  24. truthound_dashboard/core/anomaly.py +1395 -0
  25. truthound_dashboard/core/anomaly_explainer.py +633 -0
  26. truthound_dashboard/core/cache.py +206 -0
  27. truthound_dashboard/core/cached_services.py +422 -0
  28. truthound_dashboard/core/charts.py +352 -0
  29. truthound_dashboard/core/connections.py +1069 -42
  30. truthound_dashboard/core/cross_alerts.py +837 -0
  31. truthound_dashboard/core/drift_monitor.py +1477 -0
  32. truthound_dashboard/core/drift_sampling.py +669 -0
  33. truthound_dashboard/core/i18n/__init__.py +42 -0
  34. truthound_dashboard/core/i18n/detector.py +173 -0
  35. truthound_dashboard/core/i18n/messages.py +564 -0
  36. truthound_dashboard/core/lineage.py +971 -0
  37. truthound_dashboard/core/maintenance.py +443 -5
  38. truthound_dashboard/core/model_monitoring.py +1043 -0
  39. truthound_dashboard/core/notifications/channels.py +1020 -1
  40. truthound_dashboard/core/notifications/deduplication/__init__.py +143 -0
  41. truthound_dashboard/core/notifications/deduplication/policies.py +274 -0
  42. truthound_dashboard/core/notifications/deduplication/service.py +400 -0
  43. truthound_dashboard/core/notifications/deduplication/stores.py +2365 -0
  44. truthound_dashboard/core/notifications/deduplication/strategies.py +422 -0
  45. truthound_dashboard/core/notifications/dispatcher.py +43 -0
  46. truthound_dashboard/core/notifications/escalation/__init__.py +149 -0
  47. truthound_dashboard/core/notifications/escalation/backends.py +1384 -0
  48. truthound_dashboard/core/notifications/escalation/engine.py +429 -0
  49. truthound_dashboard/core/notifications/escalation/models.py +336 -0
  50. truthound_dashboard/core/notifications/escalation/scheduler.py +1187 -0
  51. truthound_dashboard/core/notifications/escalation/state_machine.py +330 -0
  52. truthound_dashboard/core/notifications/escalation/stores.py +2896 -0
  53. truthound_dashboard/core/notifications/events.py +49 -0
  54. truthound_dashboard/core/notifications/metrics/__init__.py +115 -0
  55. truthound_dashboard/core/notifications/metrics/base.py +528 -0
  56. truthound_dashboard/core/notifications/metrics/collectors.py +583 -0
  57. truthound_dashboard/core/notifications/routing/__init__.py +169 -0
  58. truthound_dashboard/core/notifications/routing/combinators.py +184 -0
  59. truthound_dashboard/core/notifications/routing/config.py +375 -0
  60. truthound_dashboard/core/notifications/routing/config_parser.py +867 -0
  61. truthound_dashboard/core/notifications/routing/engine.py +382 -0
  62. truthound_dashboard/core/notifications/routing/expression_engine.py +1269 -0
  63. truthound_dashboard/core/notifications/routing/jinja2_engine.py +774 -0
  64. truthound_dashboard/core/notifications/routing/rules.py +625 -0
  65. truthound_dashboard/core/notifications/routing/validator.py +678 -0
  66. truthound_dashboard/core/notifications/service.py +2 -0
  67. truthound_dashboard/core/notifications/stats_aggregator.py +850 -0
  68. truthound_dashboard/core/notifications/throttling/__init__.py +83 -0
  69. truthound_dashboard/core/notifications/throttling/builder.py +311 -0
  70. truthound_dashboard/core/notifications/throttling/stores.py +1859 -0
  71. truthound_dashboard/core/notifications/throttling/throttlers.py +633 -0
  72. truthound_dashboard/core/openlineage.py +1028 -0
  73. truthound_dashboard/core/plugins/__init__.py +39 -0
  74. truthound_dashboard/core/plugins/docs/__init__.py +39 -0
  75. truthound_dashboard/core/plugins/docs/extractor.py +703 -0
  76. truthound_dashboard/core/plugins/docs/renderers.py +804 -0
  77. truthound_dashboard/core/plugins/hooks/__init__.py +63 -0
  78. truthound_dashboard/core/plugins/hooks/decorators.py +367 -0
  79. truthound_dashboard/core/plugins/hooks/manager.py +403 -0
  80. truthound_dashboard/core/plugins/hooks/protocols.py +265 -0
  81. truthound_dashboard/core/plugins/lifecycle/__init__.py +41 -0
  82. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +584 -0
  83. truthound_dashboard/core/plugins/lifecycle/machine.py +419 -0
  84. truthound_dashboard/core/plugins/lifecycle/states.py +266 -0
  85. truthound_dashboard/core/plugins/loader.py +504 -0
  86. truthound_dashboard/core/plugins/registry.py +810 -0
  87. truthound_dashboard/core/plugins/reporter_executor.py +588 -0
  88. truthound_dashboard/core/plugins/sandbox/__init__.py +59 -0
  89. truthound_dashboard/core/plugins/sandbox/code_validator.py +243 -0
  90. truthound_dashboard/core/plugins/sandbox/engines.py +770 -0
  91. truthound_dashboard/core/plugins/sandbox/protocols.py +194 -0
  92. truthound_dashboard/core/plugins/sandbox.py +617 -0
  93. truthound_dashboard/core/plugins/security/__init__.py +68 -0
  94. truthound_dashboard/core/plugins/security/analyzer.py +535 -0
  95. truthound_dashboard/core/plugins/security/policies.py +311 -0
  96. truthound_dashboard/core/plugins/security/protocols.py +296 -0
  97. truthound_dashboard/core/plugins/security/signing.py +842 -0
  98. truthound_dashboard/core/plugins/security.py +446 -0
  99. truthound_dashboard/core/plugins/validator_executor.py +401 -0
  100. truthound_dashboard/core/plugins/versioning/__init__.py +51 -0
  101. truthound_dashboard/core/plugins/versioning/constraints.py +377 -0
  102. truthound_dashboard/core/plugins/versioning/dependencies.py +541 -0
  103. truthound_dashboard/core/plugins/versioning/semver.py +266 -0
  104. truthound_dashboard/core/profile_comparison.py +601 -0
  105. truthound_dashboard/core/report_history.py +570 -0
  106. truthound_dashboard/core/reporters/__init__.py +57 -0
  107. truthound_dashboard/core/reporters/base.py +296 -0
  108. truthound_dashboard/core/reporters/csv_reporter.py +155 -0
  109. truthound_dashboard/core/reporters/html_reporter.py +598 -0
  110. truthound_dashboard/core/reporters/i18n/__init__.py +65 -0
  111. truthound_dashboard/core/reporters/i18n/base.py +494 -0
  112. truthound_dashboard/core/reporters/i18n/catalogs.py +930 -0
  113. truthound_dashboard/core/reporters/json_reporter.py +160 -0
  114. truthound_dashboard/core/reporters/junit_reporter.py +233 -0
  115. truthound_dashboard/core/reporters/markdown_reporter.py +207 -0
  116. truthound_dashboard/core/reporters/pdf_reporter.py +209 -0
  117. truthound_dashboard/core/reporters/registry.py +272 -0
  118. truthound_dashboard/core/rule_generator.py +2088 -0
  119. truthound_dashboard/core/scheduler.py +822 -12
  120. truthound_dashboard/core/schema_evolution.py +858 -0
  121. truthound_dashboard/core/services.py +152 -9
  122. truthound_dashboard/core/statistics.py +718 -0
  123. truthound_dashboard/core/streaming_anomaly.py +883 -0
  124. truthound_dashboard/core/triggers/__init__.py +45 -0
  125. truthound_dashboard/core/triggers/base.py +226 -0
  126. truthound_dashboard/core/triggers/evaluators.py +609 -0
  127. truthound_dashboard/core/triggers/factory.py +363 -0
  128. truthound_dashboard/core/unified_alerts.py +870 -0
  129. truthound_dashboard/core/validation_limits.py +509 -0
  130. truthound_dashboard/core/versioning.py +709 -0
  131. truthound_dashboard/core/websocket/__init__.py +59 -0
  132. truthound_dashboard/core/websocket/manager.py +512 -0
  133. truthound_dashboard/core/websocket/messages.py +130 -0
  134. truthound_dashboard/db/__init__.py +30 -0
  135. truthound_dashboard/db/models.py +3375 -3
  136. truthound_dashboard/main.py +22 -0
  137. truthound_dashboard/schemas/__init__.py +396 -1
  138. truthound_dashboard/schemas/anomaly.py +1258 -0
  139. truthound_dashboard/schemas/base.py +4 -0
  140. truthound_dashboard/schemas/cross_alerts.py +334 -0
  141. truthound_dashboard/schemas/drift_monitor.py +890 -0
  142. truthound_dashboard/schemas/lineage.py +428 -0
  143. truthound_dashboard/schemas/maintenance.py +154 -0
  144. truthound_dashboard/schemas/model_monitoring.py +374 -0
  145. truthound_dashboard/schemas/notifications_advanced.py +1363 -0
  146. truthound_dashboard/schemas/openlineage.py +704 -0
  147. truthound_dashboard/schemas/plugins.py +1293 -0
  148. truthound_dashboard/schemas/profile.py +420 -34
  149. truthound_dashboard/schemas/profile_comparison.py +242 -0
  150. truthound_dashboard/schemas/reports.py +285 -0
  151. truthound_dashboard/schemas/rule_suggestion.py +434 -0
  152. truthound_dashboard/schemas/schema_evolution.py +164 -0
  153. truthound_dashboard/schemas/source.py +117 -2
  154. truthound_dashboard/schemas/triggers.py +511 -0
  155. truthound_dashboard/schemas/unified_alerts.py +223 -0
  156. truthound_dashboard/schemas/validation.py +25 -1
  157. truthound_dashboard/schemas/validators/__init__.py +11 -0
  158. truthound_dashboard/schemas/validators/base.py +151 -0
  159. truthound_dashboard/schemas/versioning.py +152 -0
  160. truthound_dashboard/static/index.html +2 -2
  161. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/METADATA +142 -22
  162. truthound_dashboard-1.4.0.dist-info/RECORD +239 -0
  163. truthound_dashboard/static/assets/index-BZG20KuF.js +0 -586
  164. truthound_dashboard/static/assets/index-D_HyZ3pb.css +0 -1
  165. truthound_dashboard/static/assets/unmerged_dictionaries-CtpqQBm0.js +0 -1
  166. truthound_dashboard-1.3.1.dist-info/RECORD +0 -110
  167. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/WHEEL +0 -0
  168. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/entry_points.txt +0 -0
  169. {truthound_dashboard-1.3.1.dist-info → truthound_dashboard-1.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,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
+ )