173 lines
5.0 KiB
Python
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)
|
|
|
|
|