Files
delphi-database/app/services/document_notifications.py
HotSwapp bac8cc4bd5 changes
2025-08-18 20:20:04 -05:00

173 lines
5.0 KiB
Python

"""
Document Notifications Service
Provides convenience helpers to broadcast real-time document processing
status updates over the centralized WebSocket pool. Targets both per-file
topics for end users and an admin-wide topic for monitoring.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from datetime import datetime, timezone
from uuid import uuid4
from app.core.logging import get_logger
from app.middleware.websocket_middleware import get_websocket_manager
from app.database.base import SessionLocal
from app.models.document_workflows import EventLog
logger = get_logger("document_notifications")
# Topic helpers
def topic_for_file(file_no: str) -> str:
return f"documents_{file_no}"
ADMIN_DOCUMENTS_TOPIC = "admin_documents"
# ----------------------------------------------------------------------------
# Lightweight in-memory status store for backfill
# ----------------------------------------------------------------------------
_last_status_by_file: Dict[str, Dict[str, Any]] = {}
def _record_last_status(*, file_no: str, status: str, data: Optional[Dict[str, Any]] = None) -> None:
try:
_last_status_by_file[file_no] = {
"file_no": file_no,
"status": status,
"data": dict(data or {}),
"timestamp": datetime.now(timezone.utc),
}
except Exception:
# Avoid ever failing core path
pass
def get_last_status(file_no: str) -> Optional[Dict[str, Any]]:
"""Return the last known status record for a file, if any.
Record shape: { file_no, status, data, timestamp: datetime }
"""
try:
return _last_status_by_file.get(file_no)
except Exception:
return None
async def broadcast_status(
*,
file_no: str,
status: str, # "processing" | "completed" | "failed"
data: Optional[Dict[str, Any]] = None,
user_id: Optional[int] = None,
) -> int:
"""
Broadcast a document status update to:
- The per-file topic for subscribers
- The admin monitoring topic
- Optionally to a specific user's active connections
Returns number of messages successfully sent to the per-file topic.
"""
wm = get_websocket_manager()
event_data: Dict[str, Any] = {
"file_no": file_no,
"status": status,
**(data or {}),
}
# Update in-memory last-known status for backfill
_record_last_status(file_no=file_no, status=status, data=data)
# Best-effort persistence to event log for history/backfill
try:
db = SessionLocal()
try:
ev = EventLog(
event_id=str(uuid4()),
event_type=f"document_{status}",
event_source="document_management",
file_no=file_no,
user_id=user_id,
resource_type="document",
resource_id=str(event_data.get("document_id") or event_data.get("job_id") or ""),
event_data=event_data,
previous_state=None,
new_state={"status": status},
occurred_at=datetime.now(timezone.utc),
)
db.add(ev)
db.commit()
except Exception:
try: db.rollback()
except Exception: pass
finally:
try: db.close()
except Exception: pass
except Exception:
# Never fail core path
pass
# Per-file topic broadcast
topic = topic_for_file(file_no)
sent_to_file = await wm.broadcast_to_topic(
topic=topic,
message_type=f"document_{status}",
data=event_data,
)
# Admin monitoring broadcast (best-effort)
try:
await wm.broadcast_to_topic(
topic=ADMIN_DOCUMENTS_TOPIC,
message_type="admin_document_event",
data=event_data,
)
except Exception:
# Never fail core path if admin broadcast fails
pass
# Optional direct-to-user notification
if user_id is not None:
try:
await wm.send_to_user(
user_id=user_id,
message_type=f"document_{status}",
data=event_data,
)
except Exception:
# Ignore failures to keep UX resilient
pass
logger.info(
"Document notification broadcast",
file_no=file_no,
status=status,
sent_to_file_topic=sent_to_file,
)
return sent_to_file
async def notify_processing(
*, file_no: str, user_id: Optional[int] = None, data: Optional[Dict[str, Any]] = None
) -> int:
return await broadcast_status(file_no=file_no, status="processing", data=data, user_id=user_id)
async def notify_completed(
*, file_no: str, user_id: Optional[int] = None, data: Optional[Dict[str, Any]] = None
) -> int:
return await broadcast_status(file_no=file_no, status="completed", data=data, user_id=user_id)
async def notify_failed(
*, file_no: str, user_id: Optional[int] = None, data: Optional[Dict[str, Any]] = None
) -> int:
return await broadcast_status(file_no=file_no, status="failed", data=data, user_id=user_id)