from __future__ import annotations from collections.abc import Mapping from datetime import datetime, timezone from email.message import EmailMessage from pathlib import Path from typing import Any from sqlalchemy import event, or_ from sqlalchemy.orm import Session from govoplan_core.core.notifications import NotificationDispatchRequest from govoplan_core.db.base import utcnow from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference from govoplan_notifications.backend.schemas import ( NotificationCreateRequest, NotificationPreferencesUpdateRequest, NotificationUpdateRequest, normalize_notification_action_url, ) class NotificationError(ValueError): pass PENDING_STATUSES = {"pending", "queued", "failed"} _AFTER_COMMIT_DELIVERY_IDS = "govoplan_notifications_after_commit_delivery_ids" @event.listens_for(Session, "after_commit") def _enqueue_committed_notifications(session: Session) -> None: notification_ids = tuple(session.info.pop(_AFTER_COMMIT_DELIVERY_IDS, ())) for notification_id in notification_ids: _enqueue_celery_delivery(notification_id) @event.listens_for(Session, "after_rollback") def _discard_rolled_back_notifications(session: Session) -> None: session.info.pop(_AFTER_COMMIT_DELIVERY_IDS, None) def _enqueue_notification_after_commit(session: Session, notification_id: str) -> None: pending = session.info.setdefault(_AFTER_COMMIT_DELIVERY_IDS, []) if notification_id not in pending: pending.append(notification_id) def _discard_notification_after_commit(session: Session, notification_id: str) -> None: pending = session.info.get(_AFTER_COMMIT_DELIVERY_IDS) if not isinstance(pending, list) or notification_id not in pending: return pending.remove(notification_id) if not pending: session.info.pop(_AFTER_COMMIT_DELIVERY_IDS, None) def response_datetime(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None: return value.replace(tzinfo=timezone.utc) return value.astimezone(timezone.utc) def _now() -> datetime: return utcnow() def _clean_channel(value: str) -> str: channel = value.strip().lower() if not channel: raise NotificationError("Notification channel is required") return channel def _clean_source_modules(values: list[str]) -> list[str]: cleaned: list[str] = [] seen: set[str] = set() for value in values: item = str(value).strip().lower() if not item or item in seen: continue cleaned.append(item[:100]) seen.add(item) return cleaned def _safe_notification_action_url(value: str | None) -> str | None: try: return normalize_notification_action_url(value) except ValueError: # Legacy rows predate action URL validation. Never project an unsafe # stored value back into a clickable browser link. return None def create_notification( session: Session, *, tenant_id: str, payload: NotificationCreateRequest, ) -> NotificationMessage: notification = NotificationMessage( tenant_id=tenant_id, source_module=payload.source_module, source_resource_type=payload.source_resource_type, source_resource_id=payload.source_resource_id, event_kind=payload.event_kind, channel=_clean_channel(payload.channel), recipient=payload.recipient, recipient_type=payload.recipient_type, recipient_id=payload.recipient_id, recipient_label=payload.recipient_label, subject=payload.subject, body_text=payload.body_text, body_html=payload.body_html, action_url=normalize_notification_action_url(payload.action_url), priority=payload.priority, not_before_at=payload.not_before_at, status="queued" if payload.enqueue_delivery else "pending", queued_at=_now() if payload.enqueue_delivery else None, payload=payload.payload, metadata_=payload.metadata, ) if notification.channel == "mail" and not notification.recipient: notification.status = "skipped" notification.last_error = "Mail notification has no recipient address" session.add(notification) session.flush() if payload.enqueue_delivery and notification.status == "queued": _enqueue_notification_after_commit(session, notification.id) return notification def enqueue_dispatch_request( session: Session, request: NotificationDispatchRequest, *, enqueue_delivery: bool = True, ) -> NotificationMessage: return create_notification( session, tenant_id=request.tenant_id, payload=NotificationCreateRequest( source_module=request.source_module, source_resource_type=request.source_resource_type, source_resource_id=request.source_resource_id, event_kind=request.event_kind, channel=request.channel, recipient=request.recipient, recipient_type=request.recipient_type, recipient_id=request.recipient_id, recipient_label=request.recipient_label, subject=request.subject, body_text=request.body_text, body_html=request.body_html, action_url=request.action_url, priority=request.priority, not_before_at=request.not_before_at, enqueue_delivery=enqueue_delivery, payload=dict(request.payload), metadata=dict(request.metadata), ), ) def list_notifications( session: Session, *, tenant_id: str, status: str | None = None, channel: str | None = None, source_module: str | None = None, recipient_id: str | None = None, recipient_ids: tuple[str, ...] | None = None, limit: int = 100, ) -> list[NotificationMessage]: query = session.query(NotificationMessage).filter( NotificationMessage.tenant_id == tenant_id, NotificationMessage.deleted_at.is_(None), ) if status: query = query.filter(NotificationMessage.status == status) if channel: query = query.filter(NotificationMessage.channel == _clean_channel(channel)) if source_module: query = query.filter(NotificationMessage.source_module == source_module) if recipient_ids is not None: query = query.filter(NotificationMessage.recipient_id.in_(recipient_ids)) if recipient_id: query = query.filter(NotificationMessage.recipient_id == recipient_id) return query.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()).limit(limit).all() def notification_summary( session: Session, *, tenant_id: str, user_id: str | None = None, recipient_ids: tuple[str, ...] | None = None, ) -> dict[str, int | bool]: base_query = session.query(NotificationMessage).filter( NotificationMessage.tenant_id == tenant_id, NotificationMessage.deleted_at.is_(None), ) if recipient_ids is not None: base_query = base_query.filter(NotificationMessage.recipient_id.in_(recipient_ids)) active_query = base_query.filter(NotificationMessage.status.notin_(["cancelled", "skipped"])) show_unread_badge = True if user_id: show_unread_badge = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id).show_unread_badge return { "total": base_query.count(), "unread": active_query.filter(NotificationMessage.read_at.is_(None)).count(), "pending": active_query.filter(NotificationMessage.status.in_(sorted(PENDING_STATUSES))).count(), "failed": active_query.filter(NotificationMessage.status == "failed").count(), "show_unread_badge": show_unread_badge, } def get_notification( session: Session, *, tenant_id: str, notification_id: str, recipient_ids: tuple[str, ...] | None = None, ) -> NotificationMessage: query = session.query(NotificationMessage).filter( NotificationMessage.tenant_id == tenant_id, NotificationMessage.id == notification_id, NotificationMessage.deleted_at.is_(None), ) if recipient_ids is not None: query = query.filter(NotificationMessage.recipient_id.in_(recipient_ids)) notification = ( query.first() ) if notification is None: raise NotificationError("Notification not found") return notification def update_notification( session: Session, *, tenant_id: str, notification_id: str, payload: NotificationUpdateRequest, recipient_ids: tuple[str, ...] | None = None, ) -> NotificationMessage: notification = get_notification( session, tenant_id=tenant_id, notification_id=notification_id, recipient_ids=recipient_ids, ) now = _now() if payload.status == "read": notification.read_at = notification.read_at or now elif payload.status == "acknowledged": notification.read_at = notification.read_at or now notification.acknowledged_at = notification.acknowledged_at or now elif payload.status == "cancelled": notification.status = "cancelled" notification.cancelled_at = notification.cancelled_at or now if payload.metadata is not None: notification.metadata_ = payload.metadata session.flush() return notification def deliver_notification( session: Session, *, notification_id: str, settings: object | None = None, ) -> NotificationMessage: # An explicit same-transaction delivery supersedes the deferred Celery # handoff registered when the row was created. _discard_notification_after_commit(session, notification_id) notification = session.get(NotificationMessage, notification_id) if notification is None or notification.deleted_at is not None: raise NotificationError("Notification not found") if notification.status in {"sent", "skipped", "cancelled"}: return notification if notification.not_before_at is not None and response_datetime(notification.not_before_at) > _now(): notification.status = "queued" session.flush() return notification attempt = _start_attempt(notification) try: result = _deliver_by_channel(notification, settings=settings) except Exception as exc: # noqa: BLE001 - persisted as delivery failure. _finish_attempt(attempt, status="failed", error=str(exc)) notification.status = "failed" notification.failed_at = _now() notification.last_error = str(exc) session.flush() return notification _finish_attempt(attempt, status=result["status"], provider=result.get("provider"), details=result) notification.status = result["status"] notification.sent_at = _now() if result["status"] == "sent" else notification.sent_at notification.failed_at = _now() if result["status"] == "failed" else notification.failed_at notification.last_error = result.get("error") notification.external_message_id = result.get("external_message_id") session.flush() return notification def deliver_pending( session: Session, *, tenant_id: str | None = None, limit: int = 50, settings: object | None = None, ) -> dict[str, Any]: now = _now() query = session.query(NotificationMessage).filter( NotificationMessage.deleted_at.is_(None), NotificationMessage.status.in_(sorted(PENDING_STATUSES)), or_(NotificationMessage.not_before_at.is_(None), NotificationMessage.not_before_at <= now), ) if tenant_id: query = query.filter(NotificationMessage.tenant_id == tenant_id) notifications = query.order_by(NotificationMessage.priority.desc(), NotificationMessage.created_at.asc()).limit(limit).all() result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0, "errors": []} for notification in notifications: result["processed"] += 1 delivered = deliver_notification(session, notification_id=notification.id, settings=settings) if delivered.status == "sent": result["sent"] += 1 elif delivered.status == "skipped": result["skipped"] += 1 elif delivered.status == "failed": result["failed"] += 1 if delivered.last_error: result["errors"].append(delivered.last_error) return result def get_notification_preferences(session: Session, *, tenant_id: str, user_id: str, create: bool = False) -> NotificationPreference: preference = ( session.query(NotificationPreference) .filter( NotificationPreference.tenant_id == tenant_id, NotificationPreference.user_id == user_id, ) .first() ) if preference is not None: return preference if not create: return NotificationPreference( tenant_id=tenant_id, user_id=user_id, show_unread_badge=True, email_enabled=False, email_digest_enabled=False, muted_source_modules=[], metadata_={}, ) preference = NotificationPreference( tenant_id=tenant_id, user_id=user_id, show_unread_badge=True, email_enabled=False, email_digest_enabled=False, muted_source_modules=[], metadata_={}, ) session.add(preference) session.flush() return preference def update_notification_preferences( session: Session, *, tenant_id: str, user_id: str, payload: NotificationPreferencesUpdateRequest, ) -> NotificationPreference: preference = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id, create=True) if payload.show_unread_badge is not None: preference.show_unread_badge = payload.show_unread_badge if payload.email_enabled is not None: preference.email_enabled = payload.email_enabled if payload.email_digest_enabled is not None: preference.email_digest_enabled = payload.email_digest_enabled if payload.muted_source_modules is not None: preference.muted_source_modules = _clean_source_modules(payload.muted_source_modules) if payload.metadata is not None: preference.metadata_ = payload.metadata session.flush() return preference def notification_preferences_response(preference: NotificationPreference) -> dict[str, Any]: return { "tenant_id": preference.tenant_id, "user_id": preference.user_id, "show_unread_badge": preference.show_unread_badge, "email_enabled": preference.email_enabled, "email_digest_enabled": preference.email_digest_enabled, "muted_source_modules": _clean_source_modules(preference.muted_source_modules or []), "metadata": preference.metadata_ or {}, } def _start_attempt(notification: NotificationMessage) -> NotificationDeliveryAttempt: notification.status = "sending" notification.attempt_count += 1 attempt = NotificationDeliveryAttempt( tenant_id=notification.tenant_id, notification_id=notification.id, attempt_no=notification.attempt_count, channel=notification.channel, status="sending", started_at=_now(), details={}, ) notification.attempts.append(attempt) return attempt def _finish_attempt( attempt: NotificationDeliveryAttempt, *, status: str, provider: str | None = None, error: str | None = None, details: Mapping[str, object] | None = None, ) -> None: attempt.status = status attempt.provider = provider attempt.error = error attempt.details = dict(details or {}) attempt.finished_at = _now() if details and isinstance(details.get("external_message_id"), str): attempt.external_message_id = str(details["external_message_id"]) def _deliver_by_channel(notification: NotificationMessage, *, settings: object | None) -> dict[str, Any]: if notification.channel == "inbox": return {"status": "sent", "provider": "inbox", "external_message_id": notification.id} if notification.channel == "mail": if not notification.recipient: return {"status": "skipped", "provider": "mail", "error": "Mail notification has no recipient address"} path = _write_local_mail(notification, settings=settings) return {"status": "sent", "provider": "local_file_mail", "external_message_id": str(path), "path": str(path)} return {"status": "skipped", "provider": notification.channel, "error": f"No delivery adapter configured for channel {notification.channel!r}"} def _write_local_mail(notification: NotificationMessage, *, settings: object | None) -> Path: root = Path(str(getattr(settings, "mock_mailbox_dir", "runtime/mock-mailbox"))) / "notifications" root.mkdir(parents=True, exist_ok=True) message = EmailMessage() message["Subject"] = notification.subject or f"Notification: {notification.event_kind}" message["From"] = "notifications@govoplan.local" message["To"] = notification.recipient or "undisclosed-recipients:;" message.set_content(notification.body_text or notification.subject or notification.event_kind) if notification.body_html: message.add_alternative(notification.body_html, subtype="html") filename = f"{notification.created_at.strftime('%Y%m%d%H%M%S')}-{notification.id}.eml" path = root / filename path.write_bytes(bytes(message)) return path def _enqueue_celery_delivery(notification_id: str) -> None: try: from govoplan_core.celery_app import celery from govoplan_core.settings import settings if not getattr(settings, "celery_enabled", False): return celery.send_task("govoplan.notifications.deliver", args=[notification_id], queue="notifications") except Exception: # The committed queued row is the durable source of truth. A broker or # import failure must not turn a successful database commit into an API # failure; operators/workers can recover it through deliver_pending. return def notification_response(notification: NotificationMessage) -> dict[str, Any]: return { "id": notification.id, "tenant_id": notification.tenant_id, "source_module": notification.source_module, "source_resource_type": notification.source_resource_type, "source_resource_id": notification.source_resource_id, "event_kind": notification.event_kind, "channel": notification.channel, "recipient": notification.recipient, "recipient_type": notification.recipient_type, "recipient_id": notification.recipient_id, "recipient_label": notification.recipient_label, "subject": notification.subject, "body_text": notification.body_text, "body_html": notification.body_html, "action_url": _safe_notification_action_url(notification.action_url), "priority": notification.priority, "status": notification.status, "not_before_at": response_datetime(notification.not_before_at), "queued_at": response_datetime(notification.queued_at), "sent_at": response_datetime(notification.sent_at), "failed_at": response_datetime(notification.failed_at), "read_at": response_datetime(notification.read_at), "acknowledged_at": response_datetime(notification.acknowledged_at), "cancelled_at": response_datetime(notification.cancelled_at), "attempt_count": notification.attempt_count, "last_error": notification.last_error, "external_message_id": notification.external_message_id, "payload": notification.payload or {}, "metadata": notification.metadata_ or {}, "created_at": response_datetime(notification.created_at), "updated_at": response_datetime(notification.updated_at), "attempts": [notification_attempt_response(attempt) for attempt in notification.attempts], } def notification_attempt_response(attempt: NotificationDeliveryAttempt) -> dict[str, Any]: return { "id": attempt.id, "notification_id": attempt.notification_id, "attempt_no": attempt.attempt_no, "channel": attempt.channel, "provider": attempt.provider, "status": attempt.status, "started_at": response_datetime(attempt.started_at), "finished_at": response_datetime(attempt.finished_at), "external_message_id": attempt.external_message_id, "error": attempt.error, "details": attempt.details or {}, "created_at": response_datetime(attempt.created_at), "updated_at": response_datetime(attempt.updated_at), }