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)