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,744 @@
1
+ """Notification service layer for business logic.
2
+
3
+ This module provides the service layer for managing notification
4
+ channels, rules, and logs using the repository pattern.
5
+
6
+ Services:
7
+ - NotificationChannelService: Manage notification channels
8
+ - NotificationRuleService: Manage notification rules
9
+ - NotificationLogService: Query notification logs
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from datetime import datetime, timedelta
16
+ from typing import Any
17
+
18
+ from sqlalchemy import and_, func, select
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+
21
+ from truthound_dashboard.db import (
22
+ BaseRepository,
23
+ NotificationChannel,
24
+ NotificationLog,
25
+ NotificationRule,
26
+ )
27
+
28
+ from .base import ChannelRegistry
29
+
30
+
31
+ # =============================================================================
32
+ # Repositories
33
+ # =============================================================================
34
+
35
+
36
+ class NotificationChannelRepository(BaseRepository[NotificationChannel]):
37
+ """Repository for NotificationChannel operations."""
38
+
39
+ model = NotificationChannel
40
+
41
+ async def get_active(
42
+ self,
43
+ *,
44
+ offset: int = 0,
45
+ limit: int = 100,
46
+ ) -> Sequence[NotificationChannel]:
47
+ """Get active channels only.
48
+
49
+ Args:
50
+ offset: Number to skip.
51
+ limit: Maximum to return.
52
+
53
+ Returns:
54
+ Sequence of active channels.
55
+ """
56
+ return await self.list(
57
+ offset=offset,
58
+ limit=limit,
59
+ filters=[NotificationChannel.is_active == True],
60
+ )
61
+
62
+ async def get_by_type(
63
+ self,
64
+ channel_type: str,
65
+ *,
66
+ active_only: bool = True,
67
+ ) -> Sequence[NotificationChannel]:
68
+ """Get channels by type.
69
+
70
+ Args:
71
+ channel_type: Type of channels to get.
72
+ active_only: Only return active channels.
73
+
74
+ Returns:
75
+ Sequence of channels.
76
+ """
77
+ filters = [NotificationChannel.type == channel_type]
78
+ if active_only:
79
+ filters.append(NotificationChannel.is_active == True)
80
+
81
+ return await self.list(filters=filters)
82
+
83
+
84
+ class NotificationRuleRepository(BaseRepository[NotificationRule]):
85
+ """Repository for NotificationRule operations."""
86
+
87
+ model = NotificationRule
88
+
89
+ async def get_active(
90
+ self,
91
+ *,
92
+ offset: int = 0,
93
+ limit: int = 100,
94
+ ) -> Sequence[NotificationRule]:
95
+ """Get active rules only.
96
+
97
+ Args:
98
+ offset: Number to skip.
99
+ limit: Maximum to return.
100
+
101
+ Returns:
102
+ Sequence of active rules.
103
+ """
104
+ return await self.list(
105
+ offset=offset,
106
+ limit=limit,
107
+ filters=[NotificationRule.is_active == True],
108
+ )
109
+
110
+ async def get_by_condition(
111
+ self,
112
+ condition: str,
113
+ *,
114
+ active_only: bool = True,
115
+ ) -> Sequence[NotificationRule]:
116
+ """Get rules by condition type.
117
+
118
+ Args:
119
+ condition: Condition type to filter by.
120
+ active_only: Only return active rules.
121
+
122
+ Returns:
123
+ Sequence of rules.
124
+ """
125
+ filters = [NotificationRule.condition == condition]
126
+ if active_only:
127
+ filters.append(NotificationRule.is_active == True)
128
+
129
+ return await self.list(filters=filters)
130
+
131
+ async def get_for_source(
132
+ self,
133
+ source_id: str,
134
+ *,
135
+ active_only: bool = True,
136
+ ) -> Sequence[NotificationRule]:
137
+ """Get rules that apply to a specific source.
138
+
139
+ Args:
140
+ source_id: Source ID to check.
141
+ active_only: Only return active rules.
142
+
143
+ Returns:
144
+ Sequence of rules.
145
+ """
146
+ # Get all active rules first, then filter by source
147
+ filters = []
148
+ if active_only:
149
+ filters.append(NotificationRule.is_active == True)
150
+
151
+ all_rules = await self.list(filters=filters if filters else None)
152
+
153
+ # Filter to rules that match this source
154
+ return [r for r in all_rules if r.matches_source(source_id)]
155
+
156
+
157
+ class NotificationLogRepository(BaseRepository[NotificationLog]):
158
+ """Repository for NotificationLog operations."""
159
+
160
+ model = NotificationLog
161
+
162
+ async def get_for_channel(
163
+ self,
164
+ channel_id: str,
165
+ *,
166
+ limit: int = 50,
167
+ ) -> Sequence[NotificationLog]:
168
+ """Get logs for a specific channel.
169
+
170
+ Args:
171
+ channel_id: Channel ID.
172
+ limit: Maximum to return.
173
+
174
+ Returns:
175
+ Sequence of logs.
176
+ """
177
+ return await self.list(
178
+ limit=limit,
179
+ filters=[NotificationLog.channel_id == channel_id],
180
+ order_by=NotificationLog.created_at.desc(),
181
+ )
182
+
183
+ async def get_recent(
184
+ self,
185
+ *,
186
+ hours: int = 24,
187
+ limit: int = 100,
188
+ status: str | None = None,
189
+ ) -> Sequence[NotificationLog]:
190
+ """Get recent notification logs.
191
+
192
+ Args:
193
+ hours: Number of hours to look back.
194
+ limit: Maximum to return.
195
+ status: Optional status filter.
196
+
197
+ Returns:
198
+ Sequence of logs.
199
+ """
200
+ cutoff = datetime.utcnow() - timedelta(hours=hours)
201
+ filters = [NotificationLog.created_at >= cutoff]
202
+
203
+ if status:
204
+ filters.append(NotificationLog.status == status)
205
+
206
+ return await self.list(
207
+ limit=limit,
208
+ filters=filters,
209
+ order_by=NotificationLog.created_at.desc(),
210
+ )
211
+
212
+ async def get_stats(
213
+ self,
214
+ *,
215
+ hours: int = 24,
216
+ ) -> dict[str, Any]:
217
+ """Get notification statistics.
218
+
219
+ Args:
220
+ hours: Number of hours to analyze.
221
+
222
+ Returns:
223
+ Statistics dictionary.
224
+ """
225
+ cutoff = datetime.utcnow() - timedelta(hours=hours)
226
+
227
+ # Count by status
228
+ result = await self.session.execute(
229
+ select(
230
+ NotificationLog.status,
231
+ func.count(NotificationLog.id).label("count"),
232
+ )
233
+ .where(NotificationLog.created_at >= cutoff)
234
+ .group_by(NotificationLog.status)
235
+ )
236
+ status_counts = {row.status: row.count for row in result}
237
+
238
+ # Count by channel
239
+ result = await self.session.execute(
240
+ select(
241
+ NotificationLog.channel_id,
242
+ func.count(NotificationLog.id).label("count"),
243
+ )
244
+ .where(NotificationLog.created_at >= cutoff)
245
+ .group_by(NotificationLog.channel_id)
246
+ )
247
+ channel_counts = {row.channel_id: row.count for row in result}
248
+
249
+ return {
250
+ "period_hours": hours,
251
+ "total": sum(status_counts.values()),
252
+ "by_status": status_counts,
253
+ "by_channel": channel_counts,
254
+ "success_rate": self._calculate_success_rate(status_counts),
255
+ }
256
+
257
+ def _calculate_success_rate(self, status_counts: dict[str, int]) -> float:
258
+ """Calculate success rate from status counts."""
259
+ total = sum(status_counts.values())
260
+ if total == 0:
261
+ return 100.0
262
+ sent = status_counts.get("sent", 0)
263
+ return round(sent / total * 100, 2)
264
+
265
+
266
+ # =============================================================================
267
+ # Services
268
+ # =============================================================================
269
+
270
+
271
+ class NotificationChannelService:
272
+ """Service for managing notification channels.
273
+
274
+ Provides business logic for channel CRUD operations
275
+ and validation.
276
+ """
277
+
278
+ def __init__(self, session: AsyncSession) -> None:
279
+ """Initialize service.
280
+
281
+ Args:
282
+ session: Database session.
283
+ """
284
+ self.session = session
285
+ self.repository = NotificationChannelRepository(session)
286
+
287
+ async def list(
288
+ self,
289
+ *,
290
+ offset: int = 0,
291
+ limit: int = 100,
292
+ active_only: bool = False,
293
+ channel_type: str | None = None,
294
+ ) -> Sequence[NotificationChannel]:
295
+ """List notification channels.
296
+
297
+ Args:
298
+ offset: Number to skip.
299
+ limit: Maximum to return.
300
+ active_only: Only return active channels.
301
+ channel_type: Optional type filter.
302
+
303
+ Returns:
304
+ Sequence of channels.
305
+ """
306
+ if channel_type:
307
+ channels = await self.repository.get_by_type(
308
+ channel_type, active_only=active_only
309
+ )
310
+ return channels[offset : offset + limit]
311
+
312
+ if active_only:
313
+ return await self.repository.get_active(offset=offset, limit=limit)
314
+
315
+ return await self.repository.list(offset=offset, limit=limit)
316
+
317
+ async def get_by_id(self, channel_id: str) -> NotificationChannel | None:
318
+ """Get channel by ID.
319
+
320
+ Args:
321
+ channel_id: Channel ID.
322
+
323
+ Returns:
324
+ Channel or None.
325
+ """
326
+ return await self.repository.get_by_id(channel_id)
327
+
328
+ async def create(
329
+ self,
330
+ *,
331
+ name: str,
332
+ channel_type: str,
333
+ config: dict[str, Any],
334
+ is_active: bool = True,
335
+ ) -> NotificationChannel:
336
+ """Create a new notification channel.
337
+
338
+ Args:
339
+ name: Channel name.
340
+ channel_type: Channel type (slack, email, webhook).
341
+ config: Channel configuration.
342
+ is_active: Whether channel is active.
343
+
344
+ Returns:
345
+ Created channel.
346
+
347
+ Raises:
348
+ ValueError: If channel type is unknown or config is invalid.
349
+ """
350
+ # Validate channel type
351
+ channel_class = ChannelRegistry.get(channel_type)
352
+ if channel_class is None:
353
+ available = ChannelRegistry.list_types()
354
+ raise ValueError(
355
+ f"Unknown channel type: {channel_type}. "
356
+ f"Available types: {', '.join(available)}"
357
+ )
358
+
359
+ # Validate config
360
+ errors = channel_class.validate_config(config)
361
+ if errors:
362
+ raise ValueError(f"Invalid configuration: {'; '.join(errors)}")
363
+
364
+ return await self.repository.create(
365
+ name=name,
366
+ type=channel_type,
367
+ config=config,
368
+ is_active=is_active,
369
+ )
370
+
371
+ async def update(
372
+ self,
373
+ channel_id: str,
374
+ *,
375
+ name: str | None = None,
376
+ config: dict[str, Any] | None = None,
377
+ is_active: bool | None = None,
378
+ ) -> NotificationChannel | None:
379
+ """Update a notification channel.
380
+
381
+ Args:
382
+ channel_id: Channel ID.
383
+ name: New name.
384
+ config: New configuration.
385
+ is_active: New active status.
386
+
387
+ Returns:
388
+ Updated channel or None if not found.
389
+
390
+ Raises:
391
+ ValueError: If config is invalid.
392
+ """
393
+ channel = await self.repository.get_by_id(channel_id)
394
+ if channel is None:
395
+ return None
396
+
397
+ # Validate config if provided
398
+ if config is not None:
399
+ channel_class = ChannelRegistry.get(channel.type)
400
+ if channel_class:
401
+ errors = channel_class.validate_config(config)
402
+ if errors:
403
+ raise ValueError(f"Invalid configuration: {'; '.join(errors)}")
404
+
405
+ # Update fields
406
+ update_data = {}
407
+ if name is not None:
408
+ update_data["name"] = name
409
+ if config is not None:
410
+ update_data["config"] = config
411
+ if is_active is not None:
412
+ update_data["is_active"] = is_active
413
+
414
+ if not update_data:
415
+ return channel
416
+
417
+ return await self.repository.update(channel_id, **update_data)
418
+
419
+ async def delete(self, channel_id: str) -> bool:
420
+ """Delete a notification channel.
421
+
422
+ Args:
423
+ channel_id: Channel ID.
424
+
425
+ Returns:
426
+ True if deleted.
427
+ """
428
+ return await self.repository.delete(channel_id)
429
+
430
+ async def toggle_active(
431
+ self, channel_id: str, is_active: bool
432
+ ) -> NotificationChannel | None:
433
+ """Toggle channel active status.
434
+
435
+ Args:
436
+ channel_id: Channel ID.
437
+ is_active: New active status.
438
+
439
+ Returns:
440
+ Updated channel or None if not found.
441
+ """
442
+ return await self.update(channel_id, is_active=is_active)
443
+
444
+ def get_available_types(self) -> dict[str, dict[str, Any]]:
445
+ """Get available channel types with their schemas.
446
+
447
+ Returns:
448
+ Dictionary mapping type to schema.
449
+ """
450
+ return ChannelRegistry.get_all_schemas()
451
+
452
+
453
+ class NotificationRuleService:
454
+ """Service for managing notification rules.
455
+
456
+ Provides business logic for rule CRUD operations
457
+ and condition matching.
458
+ """
459
+
460
+ # Valid condition types
461
+ VALID_CONDITIONS = [
462
+ "validation_failed",
463
+ "critical_issues",
464
+ "high_issues",
465
+ "schedule_failed",
466
+ "drift_detected",
467
+ ]
468
+
469
+ def __init__(self, session: AsyncSession) -> None:
470
+ """Initialize service.
471
+
472
+ Args:
473
+ session: Database session.
474
+ """
475
+ self.session = session
476
+ self.repository = NotificationRuleRepository(session)
477
+ self.channel_repo = NotificationChannelRepository(session)
478
+
479
+ async def list(
480
+ self,
481
+ *,
482
+ offset: int = 0,
483
+ limit: int = 100,
484
+ active_only: bool = False,
485
+ condition: str | None = None,
486
+ ) -> Sequence[NotificationRule]:
487
+ """List notification rules.
488
+
489
+ Args:
490
+ offset: Number to skip.
491
+ limit: Maximum to return.
492
+ active_only: Only return active rules.
493
+ condition: Optional condition filter.
494
+
495
+ Returns:
496
+ Sequence of rules.
497
+ """
498
+ if condition:
499
+ rules = await self.repository.get_by_condition(
500
+ condition, active_only=active_only
501
+ )
502
+ return rules[offset : offset + limit]
503
+
504
+ if active_only:
505
+ return await self.repository.get_active(offset=offset, limit=limit)
506
+
507
+ return await self.repository.list(offset=offset, limit=limit)
508
+
509
+ async def get_by_id(self, rule_id: str) -> NotificationRule | None:
510
+ """Get rule by ID.
511
+
512
+ Args:
513
+ rule_id: Rule ID.
514
+
515
+ Returns:
516
+ Rule or None.
517
+ """
518
+ return await self.repository.get_by_id(rule_id)
519
+
520
+ async def create(
521
+ self,
522
+ *,
523
+ name: str,
524
+ condition: str,
525
+ channel_ids: list[str],
526
+ condition_config: dict[str, Any] | None = None,
527
+ source_ids: list[str] | None = None,
528
+ is_active: bool = True,
529
+ ) -> NotificationRule:
530
+ """Create a new notification rule.
531
+
532
+ Args:
533
+ name: Rule name.
534
+ condition: Trigger condition type.
535
+ channel_ids: List of channel IDs to notify.
536
+ condition_config: Optional condition configuration.
537
+ source_ids: Optional source IDs to filter (None = all sources).
538
+ is_active: Whether rule is active.
539
+
540
+ Returns:
541
+ Created rule.
542
+
543
+ Raises:
544
+ ValueError: If condition is invalid or channels don't exist.
545
+ """
546
+ # Validate condition
547
+ if condition not in self.VALID_CONDITIONS:
548
+ raise ValueError(
549
+ f"Invalid condition: {condition}. "
550
+ f"Valid conditions: {', '.join(self.VALID_CONDITIONS)}"
551
+ )
552
+
553
+ # Validate channel IDs exist
554
+ if channel_ids:
555
+ for channel_id in channel_ids:
556
+ channel = await self.channel_repo.get_by_id(channel_id)
557
+ if channel is None:
558
+ raise ValueError(f"Channel not found: {channel_id}")
559
+
560
+ return await self.repository.create(
561
+ name=name,
562
+ condition=condition,
563
+ channel_ids=channel_ids,
564
+ condition_config=condition_config or {},
565
+ source_ids=source_ids,
566
+ is_active=is_active,
567
+ )
568
+
569
+ async def update(
570
+ self,
571
+ rule_id: str,
572
+ *,
573
+ name: str | None = None,
574
+ condition: str | None = None,
575
+ channel_ids: list[str] | None = None,
576
+ condition_config: dict[str, Any] | None = None,
577
+ source_ids: list[str] | None = None,
578
+ is_active: bool | None = None,
579
+ ) -> NotificationRule | None:
580
+ """Update a notification rule.
581
+
582
+ Args:
583
+ rule_id: Rule ID.
584
+ name: New name.
585
+ condition: New condition.
586
+ channel_ids: New channel IDs.
587
+ condition_config: New condition config.
588
+ source_ids: New source IDs.
589
+ is_active: New active status.
590
+
591
+ Returns:
592
+ Updated rule or None if not found.
593
+
594
+ Raises:
595
+ ValueError: If condition or channel IDs are invalid.
596
+ """
597
+ rule = await self.repository.get_by_id(rule_id)
598
+ if rule is None:
599
+ return None
600
+
601
+ # Validate condition if provided
602
+ if condition is not None and condition not in self.VALID_CONDITIONS:
603
+ raise ValueError(
604
+ f"Invalid condition: {condition}. "
605
+ f"Valid conditions: {', '.join(self.VALID_CONDITIONS)}"
606
+ )
607
+
608
+ # Validate channel IDs if provided
609
+ if channel_ids is not None:
610
+ for channel_id in channel_ids:
611
+ channel = await self.channel_repo.get_by_id(channel_id)
612
+ if channel is None:
613
+ raise ValueError(f"Channel not found: {channel_id}")
614
+
615
+ # Build update data
616
+ update_data: dict[str, Any] = {}
617
+ if name is not None:
618
+ update_data["name"] = name
619
+ if condition is not None:
620
+ update_data["condition"] = condition
621
+ if channel_ids is not None:
622
+ update_data["channel_ids"] = channel_ids
623
+ if condition_config is not None:
624
+ update_data["condition_config"] = condition_config
625
+ if source_ids is not None:
626
+ update_data["source_ids"] = source_ids
627
+ if is_active is not None:
628
+ update_data["is_active"] = is_active
629
+
630
+ if not update_data:
631
+ return rule
632
+
633
+ return await self.repository.update(rule_id, **update_data)
634
+
635
+ async def delete(self, rule_id: str) -> bool:
636
+ """Delete a notification rule.
637
+
638
+ Args:
639
+ rule_id: Rule ID.
640
+
641
+ Returns:
642
+ True if deleted.
643
+ """
644
+ return await self.repository.delete(rule_id)
645
+
646
+ async def toggle_active(
647
+ self, rule_id: str, is_active: bool
648
+ ) -> NotificationRule | None:
649
+ """Toggle rule active status.
650
+
651
+ Args:
652
+ rule_id: Rule ID.
653
+ is_active: New active status.
654
+
655
+ Returns:
656
+ Updated rule or None if not found.
657
+ """
658
+ return await self.update(rule_id, is_active=is_active)
659
+
660
+ def get_valid_conditions(self) -> list[str]:
661
+ """Get list of valid condition types.
662
+
663
+ Returns:
664
+ List of condition type strings.
665
+ """
666
+ return list(self.VALID_CONDITIONS)
667
+
668
+
669
+ class NotificationLogService:
670
+ """Service for querying notification logs.
671
+
672
+ Provides read-only access to notification delivery history.
673
+ """
674
+
675
+ def __init__(self, session: AsyncSession) -> None:
676
+ """Initialize service.
677
+
678
+ Args:
679
+ session: Database session.
680
+ """
681
+ self.session = session
682
+ self.repository = NotificationLogRepository(session)
683
+
684
+ async def list(
685
+ self,
686
+ *,
687
+ offset: int = 0,
688
+ limit: int = 50,
689
+ channel_id: str | None = None,
690
+ status: str | None = None,
691
+ hours: int | None = None,
692
+ ) -> Sequence[NotificationLog]:
693
+ """List notification logs.
694
+
695
+ Args:
696
+ offset: Number to skip.
697
+ limit: Maximum to return.
698
+ channel_id: Optional channel filter.
699
+ status: Optional status filter.
700
+ hours: Optional time range in hours.
701
+
702
+ Returns:
703
+ Sequence of logs.
704
+ """
705
+ if channel_id:
706
+ return await self.repository.get_for_channel(channel_id, limit=limit)
707
+
708
+ if hours:
709
+ return await self.repository.get_recent(
710
+ hours=hours, limit=limit, status=status
711
+ )
712
+
713
+ filters = []
714
+ if status:
715
+ filters.append(NotificationLog.status == status)
716
+
717
+ return await self.repository.list(
718
+ offset=offset,
719
+ limit=limit,
720
+ filters=filters if filters else None,
721
+ order_by=NotificationLog.created_at.desc(),
722
+ )
723
+
724
+ async def get_by_id(self, log_id: str) -> NotificationLog | None:
725
+ """Get log by ID.
726
+
727
+ Args:
728
+ log_id: Log ID.
729
+
730
+ Returns:
731
+ Log or None.
732
+ """
733
+ return await self.repository.get_by_id(log_id)
734
+
735
+ async def get_stats(self, *, hours: int = 24) -> dict[str, Any]:
736
+ """Get notification statistics.
737
+
738
+ Args:
739
+ hours: Time range in hours.
740
+
741
+ Returns:
742
+ Statistics dictionary.
743
+ """
744
+ return await self.repository.get_stats(hours=hours)