Source code for ferrosoft.apps.ferrobase.consumers
"""Django Channels WebSocket consumers for real-time frontend UI updates."""
import logging
from asgiref.sync import async_to_sync
from channels.exceptions import DenyConnection
from channels.generic.websocket import JsonWebsocketConsumer
from ferrosoft.apps.ferrobase.frontend import FrontendUIUpdater
from ferrosoft.apps.ferrobase.models import Tenant
from ferrosoft.apps.ferrobase.tenant.context import active_tenant
[docs]
class EntityIdRequired(Exception):
"""Raised when a WebSocket consumer requires an entity ID that was not provided."""
[docs]
class UIEventsConsumer(JsonWebsocketConsumer):
"""WebSocket consumer that dispatches UI update events to the frontend.
Subclasses must implement :meth:`on_initial_ui` to push the initial UI
state when a client connects. All incoming messages are processed inside
the active tenant context resolved from the connection scope.
"""
_entity_field_name = "entity_id"
def __init__(
self,
updater_class: type[FrontendUIUpdater],
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.entity_id = None
self.updater: FrontendUIUpdater | None = None
self.updater_class = updater_class
[docs]
def on_initial_ui(self):
"""Push the initial UI state to a newly connected client.
Subclasses must override this method and call ``self.send_json`` with
the appropriate payload.
"""
raise NotImplementedError
[docs]
def connect(self):
"""Resolve the optional entity ID, create the updater, and accept the connection."""
# Consumers may work in the context of an entity.
# Entity ID is passed via URL parameter.
kwargs = self.scope["url_route"]["kwargs"]
if self._entity_field_name in kwargs:
self.entity_id = kwargs[self._entity_field_name]
try:
self.updater = self.updater_class(entity_id=self.entity_id)
except EntityIdRequired:
# Updater may rely on an entity_id, in this case deny the connection.
raise DenyConnection()
# noinspection PyArgumentList
async_to_sync(self.channel_layer.group_add)(
self.updater.group_name,
self.channel_name,
)
self.accept()
[docs]
def disconnect(self, code):
"""Leave the channel group when the WebSocket connection closes."""
# noinspection PyArgumentList
async_to_sync(self.channel_layer.group_discard)(
self.updater.group_name,
self.channel_name,
)
[docs]
def receive_json(self, content, **kwargs):
"""Handle an incoming JSON message within the active tenant context.
Sends a ``ui-failed`` response and logs exceptions if processing fails.
"""
if not isinstance(content, dict):
self.send_ui_failed("expected dict")
try:
with active_tenant(Tenant.lookup(self.scope["tenant"])):
self.receive_json_tenant_context(content)
except Exception as e:
logging.exception(e)
self.send_ui_failed()
[docs]
def send_ui_failed(self, message: str | None = None):
"""Send a ``ui-failed`` transition message to the connected client.
Args:
message: Optional human-readable error detail.
"""
return self.send_json(
{
"transition": "ui-failed",
"detail": {
"message": message or "",
},
}
)
[docs]
def receive_json_tenant_context(self, content: dict):
"""Process a JSON message once the tenant context is active."""
self._work_ui_state(content)
def _work_ui_state(self, content: dict):
"""Dispatch the received UI state to the appropriate handler method."""
# Based on the received UI state, do something.
ui_state = content.get("state", None)
match ui_state:
case "Initial":
self.on_initial_ui()
case _:
self.send_ui_failed()
[docs]
def frontend_update(self, event):
"""Forward a channel-layer group message to the WebSocket client."""
self.send_json(event)