from __future__ import annotations from datetime import datetime, timedelta import json import re from threading import RLock from typing import Any from uuid import uuid4 from sqlalchemy import case, func, or_, update from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select from ..core.redaction import redact_message from ..core.time import utc_now from ..db.models import App, AuditEvent, Connector, DiscoveryRun, NotificationChannel, NotificationEvent, NotificationRule from . import media_pipeline_service, safety_center_service from .audit_logger import log_event from .notification_channels import external, home_assistant from .secret_store import set_secret SEVERITY_RANK = {'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4} SECRET_KEYS = {'token', 'password', 'secret', 'api_key', 'apikey', 'authorization', 'bearer'} _EVALUATION_LOCK = RLock() def _now() -> datetime: return utc_now() def _redact(value: Any) -> Any: if isinstance(value, str): redacted = redact_message(value) if re.search(r'(api[_-]?key|token|password|bearer)', redacted, flags=re.IGNORECASE): return '[REDACTED]' return redacted if isinstance(value, list): return [_redact(item) for item in value] if isinstance(value, dict): result: dict[str, Any] = {} for key, item in value.items(): lowered = str(key).lower() if any(secret_key in lowered for secret_key in SECRET_KEYS): continue result[str(key)] = _redact(item) return result return value def _rule_to_dict(rule: NotificationRule) -> dict[str, Any]: return { 'id': rule.id, 'name': rule.name, 'enabled': rule.enabled, 'source': rule.source, 'eventType': rule.event_type, 'severityMin': rule.severity_min, 'categories': rule.categories_json, 'targets': rule.targets_json, 'channels': rule.channels_json, 'cooldownSeconds': rule.cooldown_seconds, 'quietHours': rule.quiet_hours_json, 'conditions': rule.conditions_json, 'dedupeKeyTemplate': rule.dedupe_key_template, 'createdAt': rule.created_at.isoformat(), 'updatedAt': rule.updated_at.isoformat(), } def _event_to_dict(event: NotificationEvent) -> dict[str, Any]: payload = _redact(event.payload_json) channel_results = payload.get('channelResults', {}) if isinstance(payload, dict) else {} return { 'id': event.id, 'ruleId': event.rule_id, 'source': event.source, 'eventType': event.event_type, 'severity': event.severity, 'title': event.title, 'message': event.message, 'category': event.category, 'target': event.target, 'dedupeKey': event.dedupe_key, 'channels': event.channels_json, 'status': event.status, 'reason': event.reason, 'payload': payload, 'channelResults': channel_results, 'read': event.read, 'createdAt': event.created_at.isoformat(), 'sentAt': event.sent_at.isoformat() if event.sent_at else None, 'suppressedUntil': event.suppressed_until.isoformat() if event.suppressed_until else None, } def _channel_to_dict(channel: NotificationChannel, session: Session | None = None) -> dict[str, Any]: safe_config = { key: value for key, value in channel.config_json.items() if not any(secret_key in str(key).lower() for secret_key in SECRET_KEYS) } status = 'connected' if channel.enabled and channel.type == 'dashboard' else ('configured' if channel.enabled else 'disabled') has_secret = bool(channel.secret_refs_json) missing: list[str] = [] safe_config_alias = _redact(safe_config) if channel.type == 'home_assistant' and session is not None: ha_status = home_assistant.config_status(session, channel) status = ha_status['status'] has_secret = ha_status['hasSecret'] missing = ha_status['missing'] safe_config_alias = _redact(ha_status['safeConfig']) elif channel.type in external.SECRET_REFS and session is not None: external_status = external.config_status(session, channel) status = external_status['status'] has_secret = external_status['hasSecret'] missing = external_status['missing'] safe_config_alias = _redact(external_status['safeConfig']) return { 'id': channel.id, 'type': channel.type, 'name': channel.name, 'enabled': channel.enabled, 'config': safe_config_alias, 'safeConfig': safe_config_alias, 'secretRefs': channel.secret_refs_json, 'status': status, 'hasSecret': has_secret, 'missing': missing, 'lastError': safe_config.get('lastError'), 'lastSentAt': safe_config.get('lastSentAt'), 'lastTestAt': channel.last_test_at.isoformat() if channel.last_test_at else None, 'createdAt': channel.created_at.isoformat(), 'updatedAt': channel.updated_at.isoformat(), } def list_rules(session: Session) -> list[dict[str, Any]]: return [_rule_to_dict(rule) for rule in session.exec(select(NotificationRule).order_by(NotificationRule.name)).all()] def create_rule(session: Session, payload: dict[str, Any]) -> dict[str, Any]: rule = NotificationRule( id=str(payload.get('id') or f"notify-{uuid4().hex[:12]}"), name=str(payload.get('name') or 'Notification Rule'), enabled=bool(payload.get('enabled', True)), source=str(payload.get('source') or 'safety'), event_type=str(payload.get('eventType') or payload.get('event_type') or 'safety.finding'), severity_min=str(payload.get('severityMin') or payload.get('severity_min') or 'medium'), categories_json=list(payload.get('categories') or payload.get('categories_json') or []), targets_json=list(payload.get('targets') or payload.get('targets_json') or []), channels_json=list(payload.get('channels') or payload.get('channels_json') or ['dashboard']), cooldown_seconds=int(payload.get('cooldownSeconds') or payload.get('cooldown_seconds') or 3600), quiet_hours_json=dict(payload.get('quietHours') or payload.get('quiet_hours_json') or {}), conditions_json=dict(payload.get('conditions') or payload.get('conditions_json') or {}), dedupe_key_template=str(payload.get('dedupeKeyTemplate') or payload.get('dedupe_key_template') or '{source}:{event_type}:{target}:{category}'), ) session.add(rule) session.commit() session.refresh(rule) safety_center_service.invalidate_cache() return _rule_to_dict(rule) def update_rule(session: Session, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]: rule = session.get(NotificationRule, rule_id) if not rule: return {'success': False, 'status': 'not_found', 'id': rule_id} mapping = { 'name': 'name', 'enabled': 'enabled', 'source': 'source', 'eventType': 'event_type', 'severityMin': 'severity_min', 'cooldownSeconds': 'cooldown_seconds', 'dedupeKeyTemplate': 'dedupe_key_template', } for input_key, attr in mapping.items(): if input_key in payload: setattr(rule, attr, payload[input_key]) if 'categories' in payload: rule.categories_json = list(payload['categories']) if 'targets' in payload: rule.targets_json = list(payload['targets']) if 'channels' in payload: rule.channels_json = list(payload['channels']) if 'quietHours' in payload: rule.quiet_hours_json = dict(payload['quietHours']) if 'conditions' in payload: rule.conditions_json = dict(payload['conditions']) rule.updated_at = _now() session.add(rule) session.commit() session.refresh(rule) safety_center_service.invalidate_cache() return _rule_to_dict(rule) def delete_rule(session: Session, rule_id: str) -> dict[str, Any]: rule = session.get(NotificationRule, rule_id) if not rule: return {'success': False, 'status': 'not_found', 'id': rule_id} session.delete(rule) session.commit() safety_center_service.invalidate_cache() return {'success': True, 'status': 'deleted', 'id': rule_id} def list_channels(session: Session) -> list[dict[str, Any]]: return [_channel_to_dict(channel, session) for channel in session.exec(select(NotificationChannel).order_by(NotificationChannel.name)).all()] def create_channel(session: Session, payload: dict[str, Any]) -> dict[str, Any]: channel_type = str(payload.get('type') or 'dashboard') config = dict(payload.get('config') or payload.get('config_json') or {}) token = str(payload.get('token') or config.pop('token', '') or '').strip() if channel_type == 'home_assistant': config = home_assistant.safe_config(config) elif channel_type in external.SECRET_REFS: config = external.safe_config(channel_type, config) channel = NotificationChannel( id=str(payload.get('id') or f"channel-{uuid4().hex[:12]}"), type=channel_type, name=str(payload.get('name') or payload.get('type') or 'Dashboard'), enabled=bool(payload.get('enabled', True)), config_json=_redact(config), secret_refs_json=list(payload.get('secretRefs') or payload.get('secret_refs_json') or ([home_assistant.SECRET_REF] if channel_type == 'home_assistant' else ([external.SECRET_REFS[channel_type]] if channel_type in external.SECRET_REFS else []))), ) if channel_type == 'home_assistant' and token: set_secret(session, home_assistant.SECRET_REF, token, used_by=['notification_channel:home_assistant'], commit=False) elif channel_type in external.SECRET_REFS and token: set_secret(session, external.SECRET_REFS[channel_type], token, used_by=[f'notification_channel:{channel_type}'], commit=False) session.add(channel) session.commit() session.refresh(channel) safety_center_service.invalidate_cache() return _channel_to_dict(channel, session) def update_channel(session: Session, channel_id: str, payload: dict[str, Any]) -> dict[str, Any]: channel = session.get(NotificationChannel, channel_id) if not channel: return {'success': False, 'status': 'not_found', 'id': channel_id} if 'name' in payload: channel.name = str(payload['name']) if 'enabled' in payload: channel.enabled = bool(payload['enabled']) if 'type' in payload: channel.type = str(payload['type']) if 'config' in payload: config = dict(payload['config']) token = str(payload.get('token') or config.pop('token', '') or '').strip() if channel.type == 'home_assistant': channel.config_json = _redact(home_assistant.safe_config(config)) if token: set_secret(session, home_assistant.SECRET_REF, token, used_by=['notification_channel:home_assistant'], commit=False) channel.secret_refs_json = [home_assistant.SECRET_REF] elif channel.type in external.SECRET_REFS: channel.config_json = _redact(external.safe_config(channel.type, config)) if token: secret_ref = external.SECRET_REFS[channel.type] set_secret(session, secret_ref, token, used_by=[f'notification_channel:{channel.type}'], commit=False) channel.secret_refs_json = [secret_ref] else: channel.config_json = _redact(config) elif channel.type == 'home_assistant' and payload.get('token'): set_secret(session, home_assistant.SECRET_REF, str(payload['token']), used_by=['notification_channel:home_assistant'], commit=False) channel.secret_refs_json = [home_assistant.SECRET_REF] elif channel.type in external.SECRET_REFS and payload.get('token'): secret_ref = external.SECRET_REFS[channel.type] set_secret(session, secret_ref, str(payload['token']), used_by=[f'notification_channel:{channel.type}'], commit=False) channel.secret_refs_json = [secret_ref] if 'secretRefs' in payload: channel.secret_refs_json = list(payload['secretRefs']) channel.updated_at = _now() session.add(channel) session.commit() session.refresh(channel) safety_center_service.invalidate_cache() return _channel_to_dict(channel, session) def test_channel(session: Session, channel_id: str) -> dict[str, Any]: channel = session.get(NotificationChannel, channel_id) if not channel: return {'success': False, 'status': 'not_found', 'id': channel_id} channel.last_test_at = _now() channel.updated_at = channel.last_test_at session.add(channel) session.commit() session.refresh(channel) if channel.type == 'home_assistant': result = home_assistant.send_test_notification(session, channel) channel.config_json = { **(channel.config_json or {}), 'lastError': result.get('reason') if not result.get('success') else None, 'lastSentAt': channel.last_test_at.isoformat() if result.get('success') else channel.config_json.get('lastSentAt'), } session.add(channel) session.commit() session.refresh(channel) log_event( session, source='notification', identity='system', action_id='notification.channel.test', target=channel.id, target_type='notification_channel', risk='low', policy_decision='allowed', reason=str(result.get('reason') or result.get('status') or 'test_notification'), result='success' if result.get('success') else 'failed', redacted_metadata={'status': result.get('status'), 'missing': result.get('missing')}, ) result['channel'] = _channel_to_dict(channel, session) return result if channel.type in external.SECRET_REFS: result = external.send_notification(session, channel, {"title": "AppOps test", "message": "Dit is een gecontroleerde testmelding vanuit AppOps."}) channel.config_json = { **(channel.config_json or {}), 'lastError': result.get('reason') if not result.get('success') else None, 'lastSentAt': channel.last_test_at.isoformat() if result.get('success') else channel.config_json.get('lastSentAt'), } session.add(channel) session.commit() result['channel'] = _channel_to_dict(channel, session) return result safe = _channel_to_dict(channel, session) if not channel.enabled: return {'success': True, 'status': 'disabled', 'channel': safe} if channel.type == 'dashboard': return {'success': True, 'status': 'sent', 'channel': safe} if channel.type in {'home_assistant', 'webhook'}: configured = bool(channel.config_json.get('url') or channel.secret_refs_json) return {'success': True, 'status': 'prepared' if configured else 'missing_config', 'channel': safe} return {'success': True, 'status': 'prepared', 'channel': safe} def test_channel_connection(session: Session, channel_id: str) -> dict[str, Any]: channel = session.get(NotificationChannel, channel_id) if not channel: return {'success': False, 'status': 'not_found', 'id': channel_id} channel.last_test_at = _now() channel.updated_at = channel.last_test_at session.add(channel) session.commit() session.refresh(channel) if channel.type == 'home_assistant': result = home_assistant.test_connection(session, channel) channel.config_json = { **(channel.config_json or {}), 'lastError': result.get('status') if not result.get('success') else None, } session.add(channel) session.commit() session.refresh(channel) result['channel'] = _channel_to_dict(channel, session) return result return test_channel(session, channel_id) def list_events( session: Session, include_dismissed: bool = True, limit: int = 250, offset: int = 0, ) -> list[dict[str, Any]]: bounded_limit = max(1, min(limit, 500)) bounded_offset = max(0, offset) statement = select(NotificationEvent) if not include_dismissed: statement = statement.where(NotificationEvent.status != 'dismissed') inbox_priority = case( ( (NotificationEvent.read == False) # noqa: E712 & NotificationEvent.status.not_in(['dismissed', 'suppressed']), 0, ), (NotificationEvent.status.not_in(['dismissed', 'suppressed']), 1), (NotificationEvent.status == 'suppressed', 2), else_=3, ) statement = ( statement .order_by(inbox_priority.asc(), NotificationEvent.created_at.desc(), NotificationEvent.id.desc()) .offset(bounded_offset) .limit(bounded_limit) ) events = session.exec(statement).all() return [_event_to_dict(event) for event in events] def unread_events(session: Session, limit: int = 250, offset: int = 0) -> list[dict[str, Any]]: bounded_limit = max(1, min(limit, 500)) bounded_offset = max(0, offset) events = session.exec( select(NotificationEvent) .where(NotificationEvent.read == False) # noqa: E712 .where(NotificationEvent.status != 'dismissed') .where(NotificationEvent.status != 'suppressed') .order_by(NotificationEvent.created_at.desc(), NotificationEvent.id.desc()) .offset(bounded_offset) .limit(bounded_limit) ).all() return [_event_to_dict(event) for event in events] def mark_read(session: Session, event_id: str) -> dict[str, Any]: event = session.get(NotificationEvent, event_id) if not event: return {'success': False, 'status': 'not_found', 'id': event_id} event.read = True if event.status == 'queued': event.status = 'sent' session.add(event) session.commit() session.refresh(event) result = _event_to_dict(event) result['success'] = True return result def dismiss_event(session: Session, event_id: str) -> dict[str, Any]: event = session.get(NotificationEvent, event_id) if not event: return {'success': False, 'status': 'not_found', 'id': event_id} event.status = 'dismissed' event.read = True session.add(event) session.commit() session.refresh(event) result = _event_to_dict(event) result['success'] = True return result def mark_all_read(session: Session) -> dict[str, Any]: events = session.exec(select(NotificationEvent).where(NotificationEvent.read == False)).all() # noqa: E712 for event in events: event.read = True if event.status == 'queued': event.status = 'sent' session.add(event) session.commit() return {'success': True, 'updated': len(events)} def _severity_matches(rule: NotificationRule, event: dict[str, Any]) -> bool: return SEVERITY_RANK.get(str(event.get('severity', 'info')), 0) >= SEVERITY_RANK.get(rule.severity_min, 0) def _condition_matches(rule: NotificationRule, event: dict[str, Any]) -> bool: if rule.source != event.get('source') or rule.event_type != event.get('event_type'): return False if not _severity_matches(rule, event): return False category = str(event.get('category') or '') target = str(event.get('target') or '') if rule.categories_json and category not in rule.categories_json: return False if rule.targets_json and target not in rule.targets_json: return False title_contains = rule.conditions_json.get('titleContains') if isinstance(rule.conditions_json, dict) else None if title_contains: title = str(event.get('title') or '').lower() if not any(str(item).lower() in title for item in title_contains): return False return True def _rule_specificity(rule: NotificationRule) -> int: conditions = rule.conditions_json if isinstance(rule.conditions_json, dict) else {} constrained_fields = sum(( bool(rule.categories_json), bool(rule.targets_json), bool(conditions), )) return constrained_fields * 100 + SEVERITY_RANK.get(rule.severity_min, 0) def _dedupe_key(rule: NotificationRule, event: dict[str, Any]) -> str: fallback = str(event.get('dedupe_key') or f"{event.get('source')}:{event.get('event_type')}:{event.get('target')}:{event.get('category')}") try: return rule.dedupe_key_template.format( source=event.get('source') or '', event_type=event.get('event_type') or '', severity=event.get('severity') or '', category=event.get('category') or '', target=event.get('target') or '', id=event.get('id') or event.get('findingId') or '', ) or fallback except Exception: return fallback def _cooldown_event(session: Session, rule: NotificationRule, dedupe_key: str) -> NotificationEvent | None: cutoff = _now() - timedelta(seconds=rule.cooldown_seconds) statement = ( select(NotificationEvent) .where(NotificationEvent.rule_id == rule.id) .where(NotificationEvent.dedupe_key == dedupe_key) .where(or_( NotificationEvent.reason.is_(None), NotificationEvent.reason.not_in(['cooldown', 'quiet_hours']), )) .where(NotificationEvent.created_at >= cutoff) .order_by(NotificationEvent.created_at.desc()) ) return session.exec(statement).first() def _quiet_hours_until( rule: NotificationRule, event: dict[str, Any], now: datetime | None = None, ) -> datetime | None: quiet = rule.quiet_hours_json or {} if not quiet.get('enabled'): return None if event.get('severity') == 'critical' and quiet.get('allowCriticalOverride', True): return None try: start = int(quiet.get('startHour', 22)) % 24 end = int(quiet.get('endHour', 7)) % 24 except (TypeError, ValueError): start, end = 22, 7 current = now or _now() hour = current.hour suppressed = hour >= start or hour < end if start > end else start <= hour < end if not suppressed: return None until = current.replace(hour=end, minute=0, second=0, microsecond=0) if until <= current: until += timedelta(days=1) return until def _quiet_hours_suppressed(rule: NotificationRule, event: dict[str, Any]) -> bool: return _quiet_hours_until(rule, event) is not None def _persist_event(session: Session, rule: NotificationRule, event: dict[str, Any], status: str, reason: str | None = None, suppressed_until: datetime | None = None) -> NotificationEvent: row = NotificationEvent( id=f"notif-{uuid4().hex[:12]}", rule_id=rule.id, source=str(event.get('source') or rule.source), event_type=str(event.get('event_type') or rule.event_type), severity=str(event.get('severity') or rule.severity_min), title=_redact(str(event.get('title') or 'Notification')), message=_redact(str(event.get('message') or event.get('title') or 'Notification')), category=str(event.get('category') or '') or None, target=str(event.get('target') or '') or None, dedupe_key=_dedupe_key(rule, event), channels_json=list(rule.channels_json or ['dashboard']), status=status, reason=reason, payload_json=_redact(dict(event.get('payload') or {})), read=status == 'suppressed', sent_at=_now() if status == 'sent' else None, suppressed_until=suppressed_until, ) session.add(row) session.commit() session.refresh(row) return row def _suppressed_channel_results(channels: list[str], reason: str) -> dict[str, dict[str, Any]]: return {channel_id: {'status': 'suppressed', 'reason': reason} for channel_id in channels} def _record_suppression( session: Session, rule: NotificationRule, event: dict[str, Any], reason: str, suppressed_until: datetime, ) -> NotificationEvent: """Atomically aggregate one read-only evidence row per routine suppression identity.""" dedupe_key = _dedupe_key(rule, event) now = _now() source_payload = event.get('payload') if isinstance(event.get('payload'), dict) else {} channels = list(rule.channels_json or ['dashboard']) payload_patch = _redact({ **source_payload, 'channelResults': _suppressed_channel_results(channels, reason), }) values = { 'severity': str(event.get('severity') or rule.severity_min), 'title': _redact(str(event.get('title') or 'Notification')), 'message': _redact(str(event.get('message') or event.get('title') or 'Notification')), 'category': str(event.get('category') or '') or None, 'target': str(event.get('target') or '') or None, 'channels_json': channels, 'read': True, 'suppressed_until': suppressed_until, } def update_existing() -> bool: payload = func.json_set( func.json_patch(NotificationEvent.payload_json, json.dumps(payload_patch)), '$.suppressionCount', func.coalesce(func.json_extract(NotificationEvent.payload_json, '$.suppressionCount'), 0) + 1, '$.lastSuppressedAt', now.isoformat(), ) result = session.execute( update(NotificationEvent) .where(NotificationEvent.rule_id == rule.id) .where(NotificationEvent.dedupe_key == dedupe_key) .where(NotificationEvent.status == 'suppressed') .where(NotificationEvent.reason == reason) .values(**values, payload_json=payload) ) return bool(result.rowcount) if not update_existing(): row = NotificationEvent( id=f'notif-{uuid4()}', rule_id=rule.id, source=str(event.get('source') or rule.source), event_type=str(event.get('event_type') or rule.event_type), dedupe_key=dedupe_key, status='suppressed', reason=reason, created_at=now, payload_json={ **payload_patch, 'suppressionCount': 1, 'firstSuppressedAt': now.isoformat(), 'lastSuppressedAt': now.isoformat(), }, **values, ) session.add(row) try: session.commit() except IntegrityError: session.rollback() if not update_existing(): raise session.commit() row = session.exec( select(NotificationEvent) .where(NotificationEvent.rule_id == rule.id) .where(NotificationEvent.dedupe_key == dedupe_key) .where(NotificationEvent.status == 'suppressed') .where(NotificationEvent.reason == reason) ).one() return row def _event_payload(event: NotificationEvent) -> dict[str, Any]: return { 'id': event.id, 'ruleId': event.rule_id, 'source': event.source, 'event_type': event.event_type, 'severity': event.severity, 'title': event.title, 'message': event.message, 'category': event.category, 'target': event.target, 'dedupe_key': event.dedupe_key, } def _deliver_event(session: Session, row: NotificationEvent) -> NotificationEvent: results: dict[str, dict[str, Any]] = {} for channel_id in row.channels_json or ['dashboard']: if channel_id == 'dashboard': results[channel_id] = {'status': 'sent'} continue channel = session.get(NotificationChannel, channel_id) if not channel: results[channel_id] = {'status': 'failed', 'reason': 'channel_not_found'} continue if channel.type == 'home_assistant': result = home_assistant.send_notification(session, channel, _event_payload(row)) results[channel_id] = _redact(result) if result.get('status') == 'sent': channel.config_json = {**(channel.config_json or {}), 'lastError': None, 'lastSentAt': _now().isoformat()} elif result.get('status') == 'failed': channel.config_json = {**(channel.config_json or {}), 'lastError': result.get('reason') or 'error'} channel.updated_at = _now() session.add(channel) continue if channel.type in external.SECRET_REFS: result = external.send_notification(session, channel, _event_payload(row)) results[channel_id] = _redact(result) channel.config_json = { **(channel.config_json or {}), 'lastError': result.get('reason') if not result.get('success') else None, 'lastSentAt': _now().isoformat() if result.get('success') else channel.config_json.get('lastSentAt'), } channel.updated_at = _now() session.add(channel) continue results[channel_id] = {'status': 'suppressed', 'reason': 'unsupported_channel'} previous_retry_count = int((row.payload_json or {}).get('deliveryRetryCount') or 0) retry_count = previous_retry_count + 1 failed_channels = [channel_id for channel_id, result in results.items() if result.get('status') in {'failed', 'missing_config', 'auth_failed', 'unreachable'}] delivered_channels = [channel_id for channel_id, result in results.items() if result.get('status') == 'sent'] dead_letter = bool(failed_channels and retry_count >= 3) row.status = 'sent' if delivered_channels else ('failed' if failed_channels else 'suppressed') row.read = row.read or row.status == 'suppressed' row.reason = 'delivery_dead_letter' if dead_letter else 'partial_delivery_failed' if failed_channels and delivered_channels else 'delivery_failed' if failed_channels else None row.sent_at = _now() if delivered_channels else None row.payload_json = _redact({ **(row.payload_json or {}), 'channelResults': results, 'deliveryRetryCount': retry_count, 'deliveryPending': bool(failed_channels and not dead_letter), 'deadLetter': dead_letter, }) session.add(row) session.commit() session.refresh(row) return row def retry_failed_deliveries(session: Session, limit: int = 20) -> dict[str, Any]: bounded_limit = max(1, min(limit, 100)) rows = session.exec( select(NotificationEvent) .where(NotificationEvent.status != 'dismissed') .where(NotificationEvent.payload_json['deliveryPending'].as_boolean() == True) # noqa: E712 .order_by(NotificationEvent.created_at.asc()) .limit(bounded_limit) ).all() retried = 0 recovered = 0 dead_lettered = 0 for row in rows: payload = row.payload_json or {} if bool(payload.get('deadLetter')) or int(payload.get('deliveryRetryCount') or 0) >= 3: dead_lettered += 1 continue retried += 1 _deliver_event(session, row) if not bool((row.payload_json or {}).get('deliveryPending')) and not bool((row.payload_json or {}).get('deadLetter')): recovered += 1 elif bool((row.payload_json or {}).get('deadLetter')): dead_lettered += 1 return {'retried': retried, 'recovered': recovered, 'deadLettered': dead_lettered} def evaluate_rules(session: Session, events: list[dict[str, Any]]) -> dict[str, Any]: rules = session.exec(select(NotificationRule).where(NotificationRule.enabled == True)).all() # noqa: E712 created: list[dict[str, Any]] = [] suppressed: list[dict[str, Any]] = [] failed: list[dict[str, Any]] = [] for event in events: if event.get('event_type') == 'safety.score': continue matching_rules = [rule for rule in rules if _condition_matches(rule, event)] if not matching_rules: continue highest_specificity = max(_rule_specificity(rule) for rule in matching_rules) for rule in matching_rules: if _rule_specificity(rule) != highest_specificity: continue dedupe = _dedupe_key(rule, event) previous = _cooldown_event(session, rule, dedupe) if previous: until = previous.created_at + timedelta(seconds=rule.cooldown_seconds) row = _record_suppression(session, rule, event, 'cooldown', until) suppressed.append(_event_to_dict(row)) continue quiet_hours_until = _quiet_hours_until(rule, event) if quiet_hours_until: row = _record_suppression(session, rule, event, 'quiet_hours', quiet_hours_until) suppressed.append(_event_to_dict(row)) continue try: row = _persist_event(session, rule, event, 'sent') row = _deliver_event(session, row) created.append(_event_to_dict(row)) except Exception as exc: failed.append({'ruleId': rule.id, 'reason': _redact(str(exc))}) return {'created': len(created), 'suppressed': len(suppressed), 'failed': len(failed), 'events': created, 'suppressedEvents': suppressed, 'failedEvents': failed} def _auto_resolve_stale_connector_events(session: Session, current_events: list[dict[str, Any]]) -> int: current_targets = { str(event.get('target')) for event in current_events if event.get('source') == 'connector' and event.get('event_type') in {'connector.missing_config', 'connector.failed'} } rows = session.exec( select(NotificationEvent) .where(NotificationEvent.source == 'connector') .where(NotificationEvent.event_type.in_(['connector.missing_config', 'connector.failed'])) .where(NotificationEvent.status != 'dismissed') ).all() resolved = 0 for row in rows: if str(row.target) in current_targets: continue row.status = 'dismissed' row.reason = 'auto_resolved' row.read = True session.add(row) resolved += 1 if resolved: session.commit() return resolved def _auto_resolve_stale_safety_events(session: Session, current_events: list[dict[str, Any]]) -> int: def event_identity(event: dict[str, Any]) -> str: payload = event.get('payload') if isinstance(event.get('payload'), dict) else {} return str(payload.get('findingId') or event.get('dedupe_key') or '') current_ids = { event_identity(event) for event in current_events if event.get('source') == 'safety' and event.get('event_type') == 'safety.finding' } rows = session.exec( select(NotificationEvent) .where(NotificationEvent.source == 'safety') .where(NotificationEvent.event_type == 'safety.finding') .where(NotificationEvent.status != 'dismissed') ).all() resolved = 0 for row in rows: payload = row.payload_json if isinstance(row.payload_json, dict) else {} row_identity = str(payload.get('findingId') or row.dedupe_key) if row_identity in current_ids: continue row.status = 'dismissed' row.reason = 'auto_resolved' row.read = True session.add(row) resolved += 1 if resolved: session.commit() return resolved def _auto_resolve_stale_media_events(session: Session, current_events: list[dict[str, Any]]) -> int: current_keys = { str(event.get('dedupe_key') or '') for event in current_events if event.get('source') == 'media_pipeline' and event.get('event_type') == 'media.issue' } rows = session.exec( select(NotificationEvent) .where(NotificationEvent.source == 'media_pipeline') .where(NotificationEvent.event_type == 'media.issue') .where(NotificationEvent.status != 'dismissed') ).all() resolved = 0 for row in rows: if str(row.dedupe_key or '') in current_keys: continue row.status = 'dismissed' row.reason = 'auto_resolved' row.read = True session.add(row) resolved += 1 if resolved: session.commit() return resolved def _reconcile_current_state(session: Session) -> int: current_events = [*_connector_events(session), *_safety_events(session), *_media_events(session)] return ( _auto_resolve_stale_connector_events(session, current_events) + _auto_resolve_stale_safety_events(session, current_events) + _auto_resolve_stale_media_events(session, current_events) ) def _safety_events(session: Session) -> list[dict[str, Any]]: dashboard = safety_center_service.get_dashboard(session) events: list[dict[str, Any]] = [] for finding in dashboard.get('findings', []): if finding.get('status') != 'open': continue events.append({ 'source': 'safety', 'event_type': 'safety.finding', 'severity': finding.get('severity', 'medium'), 'title': finding.get('title', 'Safety finding'), 'message': finding.get('description') or finding.get('title') or 'Safety finding', 'category': finding.get('category') or 'safety', 'target': ','.join(finding.get('affectedResources') or [])[:160] or 'safety-center', 'dedupe_key': f"safety.finding:{finding.get('id')}", 'payload': {'findingId': finding.get('id'), 'source': finding.get('source'), 'category': finding.get('category')}, }) return events def _connector_events(session: Session) -> list[dict[str, Any]]: result = [] for connector in session.exec(select(Connector)).all(): status = str(connector.status or 'unknown') if status in {'missing_config', 'disconnected', 'error'}: event_type = 'connector.failed' if status == 'error' else 'connector.missing_config' result.append({ 'source': 'connector', 'event_type': event_type, 'severity': 'medium' if event_type == 'connector.missing_config' else 'high', 'title': f'{connector.name} connector vraagt aandacht', 'message': f'{connector.name} connector status is {status}.', 'category': 'connector_config', 'target': connector.id, 'dedupe_key': f'{event_type}:{connector.id}', 'payload': {'connectorId': connector.id, 'status': status, 'secretRef': connector.secret_ref}, }) return result def _media_events(session: Session) -> list[dict[str, Any]]: summary = media_pipeline_service.summary(session) result: list[dict[str, Any]] = [] counts = summary.get('counts', {}) for reason in summary.get('risk', {}).get('reasons', []): normalized_reason = str(reason).lower() if counts.get('stalledDownloads', 0) > 0 and 'deluge' in normalized_reason and 'stalled' in normalized_reason: continue if counts.get('failedWidefrogJobs', 0) > 0 and 'widefrog' in normalized_reason and 'fail' in normalized_reason: continue result.append({ 'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Media Pipeline issue', 'message': str(reason), 'category': 'media', 'target': 'media-pipeline', 'dedupe_key': f"media.issue:{str(reason).lower()}", 'payload': {'reason': reason}, }) if counts.get('stalledDownloads', 0) > 0: result.append({'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Deluge stalled downloads', 'message': f"{counts['stalledDownloads']} stalled download(s).", 'category': 'downloads', 'target': 'deluge', 'dedupe_key': 'media.issue:deluge:stalled', 'payload': {'count': counts['stalledDownloads']}}) if counts.get('failedWidefrogJobs', 0) > 0: result.append({'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Widefrog failed jobs', 'message': f"{counts['failedWidefrogJobs']} failed Widefrog job(s).", 'category': 'widefrog', 'target': 'widefrog', 'dedupe_key': 'media.issue:widefrog:failed', 'payload': {'count': counts['failedWidefrogJobs']}}) return result def _audit_events(session: Session) -> list[dict[str, Any]]: rows = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all() result = [] for row in rows[:50]: if row.policy_decision == 'blocked' or row.result == 'blocked': event_type = 'audit.blocked_action' if row.risk in {'high', 'critical'} else ('audit.voice_denied' if row.source == 'voice' else 'audit.blocked_action') result.append({ 'source': 'audit', 'event_type': event_type, 'severity': 'high' if row.risk in {'high', 'critical'} else 'medium', 'title': 'Blocked dangerous action attempt' if event_type == 'audit.blocked_action' else 'Voice action denied', 'message': f'{row.action_id} on {row.target} was blocked.', 'category': 'policy', 'target': row.target, 'dedupe_key': f'{event_type}:{row.action_id}:{row.target}', 'payload': {'auditId': row.id, 'action': row.action_id, 'risk': row.risk}, }) return result def _inventory_events(session: Session) -> list[dict[str, Any]]: needs_review = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712 if not needs_review: return [] return [{ 'source': 'inventory', 'event_type': 'inventory.needs_review', 'severity': 'medium', 'title': 'Inventory review nodig', 'message': f'{len(needs_review)} app(s) vragen review.', 'category': 'inventory', 'target': 'inventory', 'dedupe_key': 'inventory.needs_review', 'payload': {'count': len(needs_review)}, }] def _setup_events(session: Session) -> list[dict[str, Any]]: latest = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first() if latest: return [] return [{ 'source': 'setup', 'event_type': 'setup.incomplete', 'severity': 'medium', 'title': 'Setup incomplete', 'message': 'Discovery is nog niet uitgevoerd.', 'category': 'setup', 'target': 'setup', 'dedupe_key': 'setup.incomplete', 'payload': {'missing': 'discovery'}, }] def evaluate_source(session: Session, source: str = 'all') -> dict[str, Any]: with _EVALUATION_LOCK: return _evaluate_source_locked(session, source) def _evaluate_source_locked(session: Session, source: str = 'all') -> dict[str, Any]: source_map = { 'safety': _safety_events, 'connectors': _connector_events, 'connector': _connector_events, 'media': _media_events, 'media_pipeline': _media_events, 'audit': _audit_events, 'inventory': _inventory_events, 'setup': _setup_events, } if source == 'all': events: list[dict[str, Any]] = [] for key in ['safety', 'connectors', 'media', 'audit', 'inventory', 'setup']: events.extend(source_map[key](session)) else: events = source_map.get(source, lambda _: [])(session) resolved = 0 if source in {'all', 'connectors', 'connector'}: resolved += _auto_resolve_stale_connector_events(session, events) if source in {'all', 'safety'}: resolved += _auto_resolve_stale_safety_events(session, events) if source in {'all', 'media', 'media_pipeline'}: resolved += _auto_resolve_stale_media_events(session, events) result = evaluate_rules(session, events) result['resolved'] = resolved result['evaluated'] = len(events) result['source'] = source return result def summary(session: Session) -> dict[str, Any]: events = session.exec(select(NotificationEvent)).all() rules = session.exec(select(NotificationRule)).all() channels = session.exec(select(NotificationChannel)).all() unread = [event for event in events if not event.read and event.status not in {'dismissed', 'suppressed'}] suppressed = [event for event in events if event.status == 'suppressed'] failed = [event for event in events if event.status == 'failed'] delivered = [event for event in events if event.status not in {'dismissed', 'suppressed'}] last = max(delivered, key=lambda event: event.created_at) if delivered else None return { 'unreadCount': len(unread), 'activeRulesCount': len([rule for rule in rules if rule.enabled]), 'suppressedCount': len(suppressed), 'failedCount': len(failed), 'lastNotification': _event_to_dict(last) if last else None, 'channels': [_channel_to_dict(channel, session) for channel in channels], }