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.
- truthound_dashboard/__init__.py +11 -0
- truthound_dashboard/__main__.py +6 -0
- truthound_dashboard/api/__init__.py +15 -0
- truthound_dashboard/api/deps.py +153 -0
- truthound_dashboard/api/drift.py +179 -0
- truthound_dashboard/api/error_handlers.py +287 -0
- truthound_dashboard/api/health.py +78 -0
- truthound_dashboard/api/history.py +62 -0
- truthound_dashboard/api/middleware.py +626 -0
- truthound_dashboard/api/notifications.py +561 -0
- truthound_dashboard/api/profile.py +52 -0
- truthound_dashboard/api/router.py +83 -0
- truthound_dashboard/api/rules.py +277 -0
- truthound_dashboard/api/schedules.py +329 -0
- truthound_dashboard/api/schemas.py +136 -0
- truthound_dashboard/api/sources.py +229 -0
- truthound_dashboard/api/validations.py +125 -0
- truthound_dashboard/cli.py +226 -0
- truthound_dashboard/config.py +132 -0
- truthound_dashboard/core/__init__.py +264 -0
- truthound_dashboard/core/base.py +185 -0
- truthound_dashboard/core/cache.py +479 -0
- truthound_dashboard/core/connections.py +331 -0
- truthound_dashboard/core/encryption.py +409 -0
- truthound_dashboard/core/exceptions.py +627 -0
- truthound_dashboard/core/logging.py +488 -0
- truthound_dashboard/core/maintenance.py +542 -0
- truthound_dashboard/core/notifications/__init__.py +56 -0
- truthound_dashboard/core/notifications/base.py +390 -0
- truthound_dashboard/core/notifications/channels.py +557 -0
- truthound_dashboard/core/notifications/dispatcher.py +453 -0
- truthound_dashboard/core/notifications/events.py +155 -0
- truthound_dashboard/core/notifications/service.py +744 -0
- truthound_dashboard/core/sampling.py +626 -0
- truthound_dashboard/core/scheduler.py +311 -0
- truthound_dashboard/core/services.py +1531 -0
- truthound_dashboard/core/truthound_adapter.py +659 -0
- truthound_dashboard/db/__init__.py +67 -0
- truthound_dashboard/db/base.py +108 -0
- truthound_dashboard/db/database.py +196 -0
- truthound_dashboard/db/models.py +732 -0
- truthound_dashboard/db/repository.py +237 -0
- truthound_dashboard/main.py +309 -0
- truthound_dashboard/schemas/__init__.py +150 -0
- truthound_dashboard/schemas/base.py +96 -0
- truthound_dashboard/schemas/drift.py +118 -0
- truthound_dashboard/schemas/history.py +74 -0
- truthound_dashboard/schemas/profile.py +91 -0
- truthound_dashboard/schemas/rule.py +199 -0
- truthound_dashboard/schemas/schedule.py +88 -0
- truthound_dashboard/schemas/schema.py +121 -0
- truthound_dashboard/schemas/source.py +138 -0
- truthound_dashboard/schemas/validation.py +192 -0
- truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
- truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
- truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
- truthound_dashboard/static/index.html +15 -0
- truthound_dashboard/static/mockServiceWorker.js +349 -0
- truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
- truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
- truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
- 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)
|