changes
This commit is contained in:
172
app/services/document_notifications.py
Normal file
172
app/services/document_notifications.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user