finishing QDRO section
This commit is contained in:
213
app/services/notification.py
Normal file
213
app/services/notification.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Notification service with pluggable adapters (email, webhook).
|
||||
|
||||
Sends best-effort, non-blocking notifications for domain events such as
|
||||
QDRO status transitions. Failures are logged and never raise.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.lookups import SystemSetup
|
||||
|
||||
|
||||
class NotificationAdapter:
|
||||
def send(self, event_type: str, payload: Dict[str, Any]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class EmailAdapter(NotificationAdapter):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
smtp_host: str,
|
||||
smtp_port: int = 587,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
starttls: bool = True,
|
||||
send_from: str = "no-reply@localhost",
|
||||
default_recipients: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
self.smtp_host = smtp_host
|
||||
self.smtp_port = smtp_port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.starttls = starttls
|
||||
self.send_from = send_from
|
||||
self.default_recipients = default_recipients or []
|
||||
|
||||
def send(self, event_type: str, payload: Dict[str, Any]) -> None: # pragma: no cover - exercised via service
|
||||
override = bool(payload.get("__notify_override"))
|
||||
recipients: List[str] = [] if override else list(self.default_recipients)
|
||||
# Allow payload to specify recipients override
|
||||
extra_to = payload.get("__notify_to")
|
||||
if isinstance(extra_to, str):
|
||||
recipients.extend([addr.strip() for addr in extra_to.split(",") if addr.strip()])
|
||||
elif isinstance(extra_to, list):
|
||||
recipients.extend([str(x).strip() for x in extra_to if str(x).strip()])
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
subject = f"{event_type}"
|
||||
body = json.dumps(payload, default=str, indent=2)
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["From"] = self.send_from
|
||||
msg["To"] = ", ".join(recipients)
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
|
||||
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=10) as smtp:
|
||||
if self.starttls:
|
||||
try:
|
||||
smtp.starttls()
|
||||
except Exception:
|
||||
# Continue without TLS if not supported
|
||||
pass
|
||||
if self.username and self.password:
|
||||
smtp.login(self.username, self.password)
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
class WebhookAdapter(NotificationAdapter):
|
||||
def __init__(self, *, url: str, secret: Optional[str] = None, timeout: float = 5.0) -> None:
|
||||
self.url = url
|
||||
self.secret = secret
|
||||
self.timeout = timeout
|
||||
|
||||
def _signature(self, body: bytes) -> Optional[str]:
|
||||
if not self.secret:
|
||||
return None
|
||||
sig = hmac.new(self.secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
return f"sha256={sig}"
|
||||
|
||||
def send(self, event_type: str, payload: Dict[str, Any]) -> None: # pragma: no cover - exercised via service
|
||||
body = json.dumps({"type": event_type, "payload": payload}, default=str).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
sig = self._signature(body)
|
||||
if sig:
|
||||
headers["X-Signature"] = sig
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
client.post(self.url, content=body, headers=headers)
|
||||
except Exception:
|
||||
# Swallow errors by design
|
||||
pass
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, adapters: Iterable[NotificationAdapter]) -> None:
|
||||
self.adapters = list(adapters)
|
||||
|
||||
def emit(self, event_type: str, payload: Dict[str, Any]) -> None:
|
||||
for adapter in self.adapters:
|
||||
try:
|
||||
# If a per-event webhook override is present and this is a default webhook adapter
|
||||
# and override flag is set, skip default webhook send
|
||||
if isinstance(adapter, WebhookAdapter) and payload.get("__webhook_url") and payload.get("__webhook_override"):
|
||||
continue
|
||||
adapter.send(event_type, payload)
|
||||
except Exception:
|
||||
# Never block or raise from notification adapters
|
||||
continue
|
||||
# If explicit webhook override is provided, send a one-off webhook request
|
||||
if payload.get("__webhook_url"):
|
||||
try:
|
||||
WebhookAdapter(
|
||||
url=str(payload.get("__webhook_url")),
|
||||
secret=str(payload.get("__webhook_secret")) if payload.get("__webhook_secret") else None,
|
||||
).send(event_type, payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_default_notification_service() -> NotificationService:
|
||||
if not settings.notifications_enabled:
|
||||
return NotificationService([])
|
||||
|
||||
adapters: List[NotificationAdapter] = []
|
||||
|
||||
# Email adapter if SMTP host is configured
|
||||
if settings.smtp_host:
|
||||
default_to: List[str] = []
|
||||
if settings.qdro_notify_email_to:
|
||||
default_to = [addr.strip() for addr in settings.qdro_notify_email_to.split(",") if addr.strip()]
|
||||
email_adapter = EmailAdapter(
|
||||
smtp_host=settings.smtp_host,
|
||||
smtp_port=settings.smtp_port,
|
||||
username=settings.smtp_username,
|
||||
password=settings.smtp_password,
|
||||
starttls=settings.smtp_starttls,
|
||||
send_from=settings.notification_email_from,
|
||||
default_recipients=default_to,
|
||||
)
|
||||
adapters.append(email_adapter)
|
||||
|
||||
# Webhook adapter if URL is configured
|
||||
if settings.qdro_notify_webhook_url:
|
||||
adapters.append(
|
||||
WebhookAdapter(
|
||||
url=settings.qdro_notify_webhook_url,
|
||||
secret=settings.qdro_notify_webhook_secret,
|
||||
)
|
||||
)
|
||||
|
||||
return NotificationService(adapters)
|
||||
|
||||
|
||||
# Singleton for app code to import
|
||||
notification_service = build_default_notification_service()
|
||||
|
||||
|
||||
def _get_setting(db: Session, key: str) -> Optional[str]:
|
||||
row = db.query(SystemSetup).filter(SystemSetup.setting_key == key).first()
|
||||
return row.setting_value if row else None
|
||||
|
||||
|
||||
def resolve_qdro_routes(
|
||||
db: Session,
|
||||
*,
|
||||
file_no: Optional[str],
|
||||
plan_id: Optional[str],
|
||||
) -> Dict[str, Optional[Any]]:
|
||||
"""
|
||||
Resolve per-file or per-plan routing from SystemSetup.
|
||||
|
||||
Precedence: file-specific overrides win over plan-specific, which win over defaults.
|
||||
Returns a dict with keys: email_to (comma-separated string), webhook_url, webhook_secret.
|
||||
"""
|
||||
email_to: Optional[str] = None
|
||||
webhook_url: Optional[str] = None
|
||||
webhook_secret: Optional[str] = None
|
||||
|
||||
# File overrides
|
||||
if file_no:
|
||||
email_to = _get_setting(db, f"notifications.qdro.email.to.file.{file_no}") or email_to
|
||||
webhook_url = _get_setting(db, f"notifications.qdro.webhook.url.file.{file_no}") or webhook_url
|
||||
webhook_secret = _get_setting(db, f"notifications.qdro.webhook.secret.file.{file_no}") or webhook_secret
|
||||
|
||||
# Plan overrides (only if not set by file)
|
||||
if plan_id:
|
||||
if email_to is None:
|
||||
email_to = _get_setting(db, f"notifications.qdro.email.to.plan.{plan_id}") or email_to
|
||||
if webhook_url is None:
|
||||
webhook_url = _get_setting(db, f"notifications.qdro.webhook.url.plan.{plan_id}") or webhook_url
|
||||
if webhook_secret is None:
|
||||
webhook_secret = _get_setting(db, f"notifications.qdro.webhook.secret.plan.{plan_id}") or webhook_secret
|
||||
|
||||
return {
|
||||
"email_to": email_to,
|
||||
"webhook_url": webhook_url,
|
||||
"webhook_secret": webhook_secret,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user