truthound-dashboard 1.0.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 (62) hide show
  1. truthound_dashboard/__init__.py +11 -0
  2. truthound_dashboard/__main__.py +6 -0
  3. truthound_dashboard/api/__init__.py +15 -0
  4. truthound_dashboard/api/deps.py +153 -0
  5. truthound_dashboard/api/drift.py +179 -0
  6. truthound_dashboard/api/error_handlers.py +287 -0
  7. truthound_dashboard/api/health.py +78 -0
  8. truthound_dashboard/api/history.py +62 -0
  9. truthound_dashboard/api/middleware.py +626 -0
  10. truthound_dashboard/api/notifications.py +561 -0
  11. truthound_dashboard/api/profile.py +52 -0
  12. truthound_dashboard/api/router.py +83 -0
  13. truthound_dashboard/api/rules.py +277 -0
  14. truthound_dashboard/api/schedules.py +329 -0
  15. truthound_dashboard/api/schemas.py +136 -0
  16. truthound_dashboard/api/sources.py +229 -0
  17. truthound_dashboard/api/validations.py +125 -0
  18. truthound_dashboard/cli.py +226 -0
  19. truthound_dashboard/config.py +132 -0
  20. truthound_dashboard/core/__init__.py +264 -0
  21. truthound_dashboard/core/base.py +185 -0
  22. truthound_dashboard/core/cache.py +479 -0
  23. truthound_dashboard/core/connections.py +331 -0
  24. truthound_dashboard/core/encryption.py +409 -0
  25. truthound_dashboard/core/exceptions.py +627 -0
  26. truthound_dashboard/core/logging.py +488 -0
  27. truthound_dashboard/core/maintenance.py +542 -0
  28. truthound_dashboard/core/notifications/__init__.py +56 -0
  29. truthound_dashboard/core/notifications/base.py +390 -0
  30. truthound_dashboard/core/notifications/channels.py +557 -0
  31. truthound_dashboard/core/notifications/dispatcher.py +453 -0
  32. truthound_dashboard/core/notifications/events.py +155 -0
  33. truthound_dashboard/core/notifications/service.py +744 -0
  34. truthound_dashboard/core/sampling.py +626 -0
  35. truthound_dashboard/core/scheduler.py +311 -0
  36. truthound_dashboard/core/services.py +1531 -0
  37. truthound_dashboard/core/truthound_adapter.py +659 -0
  38. truthound_dashboard/db/__init__.py +67 -0
  39. truthound_dashboard/db/base.py +108 -0
  40. truthound_dashboard/db/database.py +196 -0
  41. truthound_dashboard/db/models.py +732 -0
  42. truthound_dashboard/db/repository.py +237 -0
  43. truthound_dashboard/main.py +309 -0
  44. truthound_dashboard/schemas/__init__.py +150 -0
  45. truthound_dashboard/schemas/base.py +96 -0
  46. truthound_dashboard/schemas/drift.py +118 -0
  47. truthound_dashboard/schemas/history.py +74 -0
  48. truthound_dashboard/schemas/profile.py +91 -0
  49. truthound_dashboard/schemas/rule.py +199 -0
  50. truthound_dashboard/schemas/schedule.py +88 -0
  51. truthound_dashboard/schemas/schema.py +121 -0
  52. truthound_dashboard/schemas/source.py +138 -0
  53. truthound_dashboard/schemas/validation.py +192 -0
  54. truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
  55. truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
  56. truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
  57. truthound_dashboard/static/index.html +15 -0
  58. truthound_dashboard/static/mockServiceWorker.js +349 -0
  59. truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
  60. truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
  61. truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
  62. truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
@@ -0,0 +1,542 @@
1
+ """Database maintenance and cleanup tasks.
2
+
3
+ This module provides automated database maintenance operations including:
4
+ - Cleanup of old validation records
5
+ - Removal of stale profile data
6
+ - Notification log cleanup
7
+ - Database optimization (VACUUM)
8
+
9
+ The maintenance system uses a configurable strategy pattern allowing
10
+ custom cleanup policies.
11
+
12
+ Example:
13
+ manager = get_maintenance_manager()
14
+ await manager.run_cleanup()
15
+ await manager.vacuum()
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ from abc import ABC, abstractmethod
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime, timedelta
25
+ from typing import Any
26
+
27
+ from sqlalchemy import delete, func, select, text
28
+
29
+ from truthound_dashboard.config import get_settings
30
+ from truthound_dashboard.db import get_session
31
+ from truthound_dashboard.db.models import (
32
+ NotificationLog,
33
+ Profile,
34
+ Source,
35
+ Validation,
36
+ )
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ @dataclass
42
+ class CleanupResult:
43
+ """Result of a cleanup operation.
44
+
45
+ Attributes:
46
+ task_name: Name of the cleanup task.
47
+ records_deleted: Number of records deleted.
48
+ duration_ms: Duration in milliseconds.
49
+ success: Whether the operation succeeded.
50
+ error: Error message if failed.
51
+ """
52
+
53
+ task_name: str
54
+ records_deleted: int = 0
55
+ duration_ms: int = 0
56
+ success: bool = True
57
+ error: str | None = None
58
+
59
+ def to_dict(self) -> dict[str, Any]:
60
+ """Convert to dictionary."""
61
+ return {
62
+ "task_name": self.task_name,
63
+ "records_deleted": self.records_deleted,
64
+ "duration_ms": self.duration_ms,
65
+ "success": self.success,
66
+ "error": self.error,
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class MaintenanceConfig:
72
+ """Configuration for maintenance tasks.
73
+
74
+ Attributes:
75
+ validation_retention_days: Days to keep validation records.
76
+ profile_keep_per_source: Number of profiles to keep per source.
77
+ notification_log_retention_days: Days to keep notification logs.
78
+ run_vacuum: Whether to run VACUUM after cleanup.
79
+ """
80
+
81
+ validation_retention_days: int = 90
82
+ profile_keep_per_source: int = 5
83
+ notification_log_retention_days: int = 30
84
+ run_vacuum: bool = True
85
+ enabled: bool = True
86
+
87
+
88
+ class CleanupStrategy(ABC):
89
+ """Abstract base class for cleanup strategies.
90
+
91
+ Subclass this to implement custom cleanup logic.
92
+ """
93
+
94
+ @property
95
+ @abstractmethod
96
+ def name(self) -> str:
97
+ """Get strategy name."""
98
+ ...
99
+
100
+ @abstractmethod
101
+ async def execute(self, config: MaintenanceConfig) -> CleanupResult:
102
+ """Execute cleanup strategy.
103
+
104
+ Args:
105
+ config: Maintenance configuration.
106
+
107
+ Returns:
108
+ CleanupResult with operation details.
109
+ """
110
+ ...
111
+
112
+
113
+ class ValidationCleanupStrategy(CleanupStrategy):
114
+ """Cleanup strategy for old validation records."""
115
+
116
+ @property
117
+ def name(self) -> str:
118
+ return "validation_cleanup"
119
+
120
+ async def execute(self, config: MaintenanceConfig) -> CleanupResult:
121
+ """Remove validation records older than retention period."""
122
+ start_time = datetime.utcnow()
123
+
124
+ try:
125
+ cutoff = datetime.utcnow() - timedelta(
126
+ days=config.validation_retention_days
127
+ )
128
+
129
+ async with get_session() as session:
130
+ # Count records to delete
131
+ count_result = await session.execute(
132
+ select(func.count(Validation.id)).where(
133
+ Validation.created_at < cutoff
134
+ )
135
+ )
136
+ count = count_result.scalar() or 0
137
+
138
+ # Delete old records
139
+ if count > 0:
140
+ await session.execute(
141
+ delete(Validation).where(Validation.created_at < cutoff)
142
+ )
143
+
144
+ duration = int(
145
+ (datetime.utcnow() - start_time).total_seconds() * 1000
146
+ )
147
+
148
+ logger.info(
149
+ f"Validation cleanup: deleted {count} records "
150
+ f"older than {config.validation_retention_days} days"
151
+ )
152
+
153
+ return CleanupResult(
154
+ task_name=self.name,
155
+ records_deleted=count,
156
+ duration_ms=duration,
157
+ success=True,
158
+ )
159
+
160
+ except Exception as e:
161
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
162
+ logger.error(f"Validation cleanup failed: {e}")
163
+ return CleanupResult(
164
+ task_name=self.name,
165
+ duration_ms=duration,
166
+ success=False,
167
+ error=str(e),
168
+ )
169
+
170
+
171
+ class ProfileCleanupStrategy(CleanupStrategy):
172
+ """Cleanup strategy for keeping only recent profiles per source."""
173
+
174
+ @property
175
+ def name(self) -> str:
176
+ return "profile_cleanup"
177
+
178
+ async def execute(self, config: MaintenanceConfig) -> CleanupResult:
179
+ """Keep only the most recent N profiles per source."""
180
+ start_time = datetime.utcnow()
181
+ total_deleted = 0
182
+
183
+ try:
184
+ async with get_session() as session:
185
+ # Get all source IDs
186
+ sources_result = await session.execute(select(Source.id))
187
+ source_ids = [row[0] for row in sources_result]
188
+
189
+ for source_id in source_ids:
190
+ # Get IDs of profiles to keep
191
+ keep_result = await session.execute(
192
+ select(Profile.id)
193
+ .where(Profile.source_id == source_id)
194
+ .order_by(Profile.created_at.desc())
195
+ .limit(config.profile_keep_per_source)
196
+ )
197
+ keep_ids = [row[0] for row in keep_result]
198
+
199
+ if not keep_ids:
200
+ continue
201
+
202
+ # Count and delete excess profiles
203
+ count_result = await session.execute(
204
+ select(func.count(Profile.id))
205
+ .where(Profile.source_id == source_id)
206
+ .where(Profile.id.not_in(keep_ids))
207
+ )
208
+ count = count_result.scalar() or 0
209
+
210
+ if count > 0:
211
+ await session.execute(
212
+ delete(Profile)
213
+ .where(Profile.source_id == source_id)
214
+ .where(Profile.id.not_in(keep_ids))
215
+ )
216
+ total_deleted += count
217
+
218
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
219
+
220
+ logger.info(
221
+ f"Profile cleanup: deleted {total_deleted} records, "
222
+ f"keeping {config.profile_keep_per_source} per source"
223
+ )
224
+
225
+ return CleanupResult(
226
+ task_name=self.name,
227
+ records_deleted=total_deleted,
228
+ duration_ms=duration,
229
+ success=True,
230
+ )
231
+
232
+ except Exception as e:
233
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
234
+ logger.error(f"Profile cleanup failed: {e}")
235
+ return CleanupResult(
236
+ task_name=self.name,
237
+ duration_ms=duration,
238
+ success=False,
239
+ error=str(e),
240
+ )
241
+
242
+
243
+ class NotificationLogCleanupStrategy(CleanupStrategy):
244
+ """Cleanup strategy for old notification logs."""
245
+
246
+ @property
247
+ def name(self) -> str:
248
+ return "notification_log_cleanup"
249
+
250
+ async def execute(self, config: MaintenanceConfig) -> CleanupResult:
251
+ """Remove notification logs older than retention period."""
252
+ start_time = datetime.utcnow()
253
+
254
+ try:
255
+ cutoff = datetime.utcnow() - timedelta(
256
+ days=config.notification_log_retention_days
257
+ )
258
+
259
+ async with get_session() as session:
260
+ # Count records to delete
261
+ count_result = await session.execute(
262
+ select(func.count(NotificationLog.id)).where(
263
+ NotificationLog.created_at < cutoff
264
+ )
265
+ )
266
+ count = count_result.scalar() or 0
267
+
268
+ # Delete old records
269
+ if count > 0:
270
+ await session.execute(
271
+ delete(NotificationLog).where(
272
+ NotificationLog.created_at < cutoff
273
+ )
274
+ )
275
+
276
+ duration = int(
277
+ (datetime.utcnow() - start_time).total_seconds() * 1000
278
+ )
279
+
280
+ logger.info(
281
+ f"Notification log cleanup: deleted {count} records "
282
+ f"older than {config.notification_log_retention_days} days"
283
+ )
284
+
285
+ return CleanupResult(
286
+ task_name=self.name,
287
+ records_deleted=count,
288
+ duration_ms=duration,
289
+ success=True,
290
+ )
291
+
292
+ except Exception as e:
293
+ duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
294
+ logger.error(f"Notification log cleanup failed: {e}")
295
+ return CleanupResult(
296
+ task_name=self.name,
297
+ duration_ms=duration,
298
+ success=False,
299
+ error=str(e),
300
+ )
301
+
302
+
303
+ @dataclass
304
+ class MaintenanceReport:
305
+ """Report of a complete maintenance run.
306
+
307
+ Attributes:
308
+ started_at: When maintenance started.
309
+ completed_at: When maintenance completed.
310
+ results: List of cleanup results.
311
+ vacuum_performed: Whether VACUUM was run.
312
+ """
313
+
314
+ started_at: datetime
315
+ completed_at: datetime | None = None
316
+ results: list[CleanupResult] = field(default_factory=list)
317
+ vacuum_performed: bool = False
318
+ vacuum_error: str | None = None
319
+
320
+ @property
321
+ def total_deleted(self) -> int:
322
+ """Get total records deleted across all tasks."""
323
+ return sum(r.records_deleted for r in self.results)
324
+
325
+ @property
326
+ def total_duration_ms(self) -> int:
327
+ """Get total duration in milliseconds."""
328
+ if self.completed_at:
329
+ return int((self.completed_at - self.started_at).total_seconds() * 1000)
330
+ return 0
331
+
332
+ @property
333
+ def success(self) -> bool:
334
+ """Check if all tasks succeeded."""
335
+ return all(r.success for r in self.results) and self.vacuum_error is None
336
+
337
+ def to_dict(self) -> dict[str, Any]:
338
+ """Convert to dictionary."""
339
+ return {
340
+ "started_at": self.started_at.isoformat(),
341
+ "completed_at": (
342
+ self.completed_at.isoformat() if self.completed_at else None
343
+ ),
344
+ "results": [r.to_dict() for r in self.results],
345
+ "total_deleted": self.total_deleted,
346
+ "total_duration_ms": self.total_duration_ms,
347
+ "vacuum_performed": self.vacuum_performed,
348
+ "vacuum_error": self.vacuum_error,
349
+ "success": self.success,
350
+ }
351
+
352
+
353
+ class MaintenanceManager:
354
+ """Manager for database maintenance operations.
355
+
356
+ Coordinates cleanup strategies and provides a unified interface
357
+ for maintenance tasks.
358
+
359
+ Usage:
360
+ manager = MaintenanceManager()
361
+ manager.register_strategy(ValidationCleanupStrategy())
362
+ report = await manager.run_cleanup()
363
+ """
364
+
365
+ def __init__(self, config: MaintenanceConfig | None = None) -> None:
366
+ """Initialize maintenance manager.
367
+
368
+ Args:
369
+ config: Maintenance configuration. Uses defaults if not provided.
370
+ """
371
+ self._config = config or MaintenanceConfig()
372
+ self._strategies: list[CleanupStrategy] = []
373
+
374
+ @property
375
+ def config(self) -> MaintenanceConfig:
376
+ """Get maintenance configuration."""
377
+ return self._config
378
+
379
+ def register_strategy(self, strategy: CleanupStrategy) -> None:
380
+ """Register a cleanup strategy.
381
+
382
+ Args:
383
+ strategy: Cleanup strategy to register.
384
+ """
385
+ self._strategies.append(strategy)
386
+
387
+ def register_default_strategies(self) -> None:
388
+ """Register all default cleanup strategies."""
389
+ self._strategies = [
390
+ ValidationCleanupStrategy(),
391
+ ProfileCleanupStrategy(),
392
+ NotificationLogCleanupStrategy(),
393
+ ]
394
+
395
+ async def run_cleanup(self) -> MaintenanceReport:
396
+ """Run all registered cleanup strategies.
397
+
398
+ Returns:
399
+ MaintenanceReport with all results.
400
+ """
401
+ if not self._config.enabled:
402
+ logger.info("Maintenance is disabled")
403
+ return MaintenanceReport(
404
+ started_at=datetime.utcnow(),
405
+ completed_at=datetime.utcnow(),
406
+ )
407
+
408
+ report = MaintenanceReport(started_at=datetime.utcnow())
409
+
410
+ for strategy in self._strategies:
411
+ try:
412
+ result = await strategy.execute(self._config)
413
+ report.results.append(result)
414
+ except Exception as e:
415
+ logger.error(f"Strategy {strategy.name} failed: {e}")
416
+ report.results.append(
417
+ CleanupResult(
418
+ task_name=strategy.name,
419
+ success=False,
420
+ error=str(e),
421
+ )
422
+ )
423
+
424
+ # Run VACUUM if configured
425
+ if self._config.run_vacuum:
426
+ try:
427
+ await self.vacuum()
428
+ report.vacuum_performed = True
429
+ except Exception as e:
430
+ report.vacuum_error = str(e)
431
+ logger.error(f"VACUUM failed: {e}")
432
+
433
+ report.completed_at = datetime.utcnow()
434
+
435
+ logger.info(
436
+ f"Maintenance completed: {report.total_deleted} records deleted "
437
+ f"in {report.total_duration_ms}ms"
438
+ )
439
+
440
+ return report
441
+
442
+ async def vacuum(self) -> None:
443
+ """Run SQLite VACUUM to reclaim space."""
444
+ from truthound_dashboard.db.database import get_engine
445
+
446
+ logger.info("Running database VACUUM")
447
+
448
+ engine = get_engine()
449
+ async with engine.begin() as conn:
450
+ # VACUUM cannot run in a transaction, so we need raw connection
451
+ await conn.execute(text("VACUUM"))
452
+
453
+ logger.info("Database VACUUM completed")
454
+
455
+ async def run_task(self, task_name: str) -> CleanupResult | None:
456
+ """Run a specific cleanup task by name.
457
+
458
+ Args:
459
+ task_name: Name of the task to run.
460
+
461
+ Returns:
462
+ CleanupResult or None if task not found.
463
+ """
464
+ for strategy in self._strategies:
465
+ if strategy.name == task_name:
466
+ return await strategy.execute(self._config)
467
+ return None
468
+
469
+
470
+ # Singleton instance
471
+ _manager: MaintenanceManager | None = None
472
+
473
+
474
+ def get_maintenance_manager() -> MaintenanceManager:
475
+ """Get maintenance manager singleton.
476
+
477
+ Returns:
478
+ MaintenanceManager with default strategies registered.
479
+ """
480
+ global _manager
481
+ if _manager is None:
482
+ _manager = MaintenanceManager()
483
+ _manager.register_default_strategies()
484
+ return _manager
485
+
486
+
487
+ def reset_maintenance_manager() -> None:
488
+ """Reset maintenance manager singleton (for testing)."""
489
+ global _manager
490
+ _manager = None
491
+
492
+
493
+ # Convenience functions for scheduled tasks
494
+ async def cleanup_old_validations(days: int | None = None) -> CleanupResult:
495
+ """Cleanup old validation records.
496
+
497
+ Args:
498
+ days: Override retention days. Uses config default if not specified.
499
+
500
+ Returns:
501
+ CleanupResult with operation details.
502
+ """
503
+ manager = get_maintenance_manager()
504
+ if days is not None:
505
+ manager.config.validation_retention_days = days
506
+ return await ValidationCleanupStrategy().execute(manager.config)
507
+
508
+
509
+ async def cleanup_old_profiles(keep_per_source: int | None = None) -> CleanupResult:
510
+ """Cleanup old profile records.
511
+
512
+ Args:
513
+ keep_per_source: Override profiles to keep. Uses config default if not specified.
514
+
515
+ Returns:
516
+ CleanupResult with operation details.
517
+ """
518
+ manager = get_maintenance_manager()
519
+ if keep_per_source is not None:
520
+ manager.config.profile_keep_per_source = keep_per_source
521
+ return await ProfileCleanupStrategy().execute(manager.config)
522
+
523
+
524
+ async def cleanup_notification_logs(days: int | None = None) -> CleanupResult:
525
+ """Cleanup old notification logs.
526
+
527
+ Args:
528
+ days: Override retention days. Uses config default if not specified.
529
+
530
+ Returns:
531
+ CleanupResult with operation details.
532
+ """
533
+ manager = get_maintenance_manager()
534
+ if days is not None:
535
+ manager.config.notification_log_retention_days = days
536
+ return await NotificationLogCleanupStrategy().execute(manager.config)
537
+
538
+
539
+ async def vacuum_database() -> None:
540
+ """Run database VACUUM."""
541
+ manager = get_maintenance_manager()
542
+ await manager.vacuum()
@@ -0,0 +1,56 @@
1
+ """Notification system for truthound dashboard.
2
+
3
+ This package provides an extensible notification system with support for
4
+ multiple channels (Slack, Email, Webhook) and configurable rules.
5
+
6
+ Architecture:
7
+ - BaseNotificationChannel: Abstract base for channel implementations
8
+ - ChannelRegistry: Registry for channel type discovery
9
+ - NotificationDispatcher: Orchestrates notification delivery
10
+ - NotificationService: Business logic for notifications
11
+
12
+ Example:
13
+ # Register a custom channel
14
+ @ChannelRegistry.register("custom")
15
+ class CustomChannel(BaseNotificationChannel):
16
+ async def send(self, message: str, **kwargs) -> bool:
17
+ ...
18
+
19
+ # Send notifications
20
+ dispatcher = get_dispatcher()
21
+ await dispatcher.notify_validation_failed(validation)
22
+ """
23
+
24
+ from .base import (
25
+ BaseNotificationChannel,
26
+ ChannelRegistry,
27
+ NotificationEvent,
28
+ NotificationResult,
29
+ )
30
+ from .channels import EmailChannel, SlackChannel, WebhookChannel
31
+ from .dispatcher import NotificationDispatcher, create_dispatcher, get_dispatcher
32
+ from .events import (
33
+ DriftDetectedEvent,
34
+ ScheduleFailedEvent,
35
+ ValidationFailedEvent,
36
+ )
37
+
38
+ __all__ = [
39
+ # Base classes
40
+ "BaseNotificationChannel",
41
+ "ChannelRegistry",
42
+ "NotificationEvent",
43
+ "NotificationResult",
44
+ # Channel implementations
45
+ "SlackChannel",
46
+ "EmailChannel",
47
+ "WebhookChannel",
48
+ # Dispatcher
49
+ "NotificationDispatcher",
50
+ "create_dispatcher",
51
+ "get_dispatcher",
52
+ # Events
53
+ "ValidationFailedEvent",
54
+ "ScheduleFailedEvent",
55
+ "DriftDetectedEvent",
56
+ ]