539 lines
20 KiB
Python
539 lines
20 KiB
Python
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),
|
|
}
|