from loguru import logger from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.core.cache import cache from asgiref.sync import sync_to_async from channels.db import database_sync_to_async from django.core.exceptions import ValidationError from atlas.models import MultiuserSession import urllib.parse class MultiuserConsumer(AsyncJsonWebsocketConsumer): @database_sync_to_async def get_session(self, session_id): try: return MultiuserSession.objects.select_related('creator').get(id=session_id) except (MultiuserSession.DoesNotExist, ValidationError): return None async def connect(self): self.room_name = self.scope['url_route']['kwargs']['room_name'] self.room_group_name = f'multiuser_{self.room_name}' self.user = self.scope.get('user') # Get session from DB using self.room_name session = await self.get_session(self.room_name) if not session: logger.warning("[Multiuser WS] Rejecting connection: session {} not found", self.room_name) await self.close() return # Determine username and staff privilege if self.user and self.user.is_authenticated: self.username = self.user.username # Manager is the creator or a site admin/staff self.is_staff = (self.user == session.creator) or self.user.is_staff else: self.username = f"Guest_{self.channel_name[-4:]}" self.is_staff = False # Parse query string for client_type (default to 'viewer') query_string = self.scope.get('query_string', b'').decode('utf-8') params = urllib.parse.parse_qs(query_string) self.client_type = params.get('client_type', ['viewer'])[0] logger.info( "[Multiuser WS] Connect request: room={}, user={}, type={}, channel={}", self.room_name, self.username, self.client_type, self.channel_name ) # Add to channels group await self.channel_layer.group_add( self.room_group_name, self.channel_name ) await self.accept() await self.send_json({ 'type': 'welcome', 'channel_name': self.channel_name }) if self.client_type == 'viewer': # Update room state in cache & notify others await self.add_user_to_room() await self.broadcast_room_status() else: # Passive session listener: just broadcast the current room status to this new client logger.info("[Multiuser WS] Passive listener connected: {}", self.channel_name) await self.broadcast_room_status() async def disconnect(self, close_code): logger.info( "[Multiuser WS] Disconnect: room={}, user={}, type={}, channel={}, code={}", self.room_name, self.username, self.client_type, self.channel_name, close_code ) # Remove from channels group await self.channel_layer.group_discard( self.room_group_name, self.channel_name ) # Update room state in cache & notify others if self.client_type == 'viewer': await self.remove_user_from_room() await self.broadcast_room_status() async def receive_json(self, content): msg_type = content.get('type') logger.info( "[Multiuser WS] Received message from user={} (type={}): {}", self.username, msg_type, content ) if self.client_type != 'viewer': logger.warning( "[Multiuser WS] Rejected message from passive listener user={} (type={})", self.username, msg_type ) return if msg_type == 'viewer_state': # Replicate view state changes from the active controller to others if await self.has_control(): logger.debug("[Multiuser WS] Broadcasting viewer_state from user={}", self.username) await self.channel_layer.group_send( self.room_group_name, { 'type': 'viewer_state_update', 'sender': self.channel_name, 'data': content.get('data') } ) else: logger.warning( "[Multiuser WS] viewer_state rejected: user={} does not have control", self.username ) elif msg_type == 'annotations': # Replicate annotation changes from the active controller to others if await self.has_control(): logger.debug("[Multiuser WS] Broadcasting annotations from user={}", self.username) await self.channel_layer.group_send( self.room_group_name, { 'type': 'annotations_update', 'sender': self.channel_name, 'data': content.get('data') } ) else: logger.warning( "[Multiuser WS] annotations rejected: user={} does not have control", self.username ) elif msg_type == 'request_control': # Broadcast a control request to all participants (so the admin/controller can grant it) logger.info("[Multiuser WS] Control request from user={}", self.username) await self.channel_layer.group_send( self.room_group_name, { 'type': 'control_request', 'sender_name': self.username, 'sender_channel': self.channel_name } ) elif msg_type == 'take_control': # Supervisors/admins can immediately assume control if self.is_staff: logger.info("[Multiuser WS] Supervisor user={} taking control", self.username) await self.set_controller(self.channel_name, self.username) await self.broadcast_room_status() else: logger.warning( "[Multiuser WS] take_control rejected: user={} is not supervisor/staff", self.username ) elif msg_type == 'grant_control': # The active controller or a supervisor can hand over control to someone else if self.is_staff or await self.has_control(): target_channel = content.get('target_channel') target_username = content.get('target_username') if target_channel: logger.info( "[Multiuser WS] Control granted by user={} to target_user={} ({})", self.username, target_username, target_channel ) await self.set_controller(target_channel, target_username) await self.broadcast_room_status() else: logger.warning( "[Multiuser WS] grant_control rejected: user={} is not controller/staff", self.username ) elif msg_type == 'release_control': # Relinquish control if await self.has_control(): logger.info("[Multiuser WS] Control released by user={}", self.username) await self.set_controller(None, None) await self.broadcast_room_status() else: logger.warning( "[Multiuser WS] release_control rejected: user={} is not active controller", self.username ) # Channels group event handlers async def viewer_state_update(self, event): if event['sender'] != self.channel_name: logger.debug("[Multiuser WS] Forwarding viewer_state_update to channel={}", self.channel_name) await self.send_json({ 'type': 'viewer_state', 'data': event['data'] }) async def annotations_update(self, event): if event['sender'] != self.channel_name: logger.debug("[Multiuser WS] Forwarding annotations_update to channel={}", self.channel_name) await self.send_json({ 'type': 'annotations', 'data': event['data'] }) async def control_request(self, event): logger.debug("[Multiuser WS] Forwarding control_request to channel={}", self.channel_name) await self.send_json({ 'type': 'control_request', 'sender_name': event['sender_name'], 'sender_channel': event['sender_channel'] }) async def room_status_update(self, event): logger.debug("[Multiuser WS] Forwarding room_status_update to channel={}", self.channel_name) await self.send_json({ 'type': 'room_status', 'users': event['users'], 'controller_channel': event['controller_channel'], 'controller_username': event['controller_username'] }) async def change_case(self, event): logger.info("[Multiuser WS] Forwarding change_case (case_id={}) to channel={}", event['case_id'], self.channel_name) await self.send_json({ 'type': 'change_case', 'case_id': event['case_id'] }) # Cache state helpers (using sync_to_async for thread-safe Memcached/Redis interactions) def get_cache_key(self): return f"multiuser_room_{self.room_name}" @sync_to_async def _get_state(self): return cache.get(self.get_cache_key()) @sync_to_async def _set_state(self, state): cache.set(self.get_cache_key(), state, timeout=86400) # Expire after 1 day async def add_user_to_room(self): state = await self._get_state() if not state: state = { 'users': {}, 'controller_channel': None, 'controller_username': None } # Remove any existing channel names for the same username to prevent duplicate presence counts on refresh for old_channel in list(state['users'].keys()): if state['users'][old_channel]['username'] == self.username: logger.info( "[Multiuser WS] Pruning stale channel name {} for user={}", old_channel, self.username ) state['users'].pop(old_channel, None) state['users'][self.channel_name] = { 'username': self.username, 'is_staff': self.is_staff } # Auto-assign control if no active controller exists or the previous controller was pruned if not state['controller_channel'] or state['controller_channel'] not in state['users']: state['controller_channel'] = self.channel_name state['controller_username'] = self.username logger.info( "[Multiuser WS] Control assigned to user={} (channel={})", self.username, self.channel_name ) await self._set_state(state) async def remove_user_from_room(self): state = await self._get_state() if state: state['users'].pop(self.channel_name, None) # If the leaving user was in control, elect a replacement if state['controller_channel'] == self.channel_name: if state['users']: next_channel = list(state['users'].keys())[0] state['controller_channel'] = next_channel state['controller_username'] = state['users'][next_channel]['username'] logger.info( "[Multiuser WS] Control passed to user={} (channel={})", state['controller_username'], next_channel ) else: state['controller_channel'] = None state['controller_username'] = None logger.info("[Multiuser WS] Room is empty, control cleared") await self._set_state(state) async def has_control(self): state = await self._get_state() return state and state['controller_channel'] == self.channel_name async def set_controller(self, channel_name, username): state = await self._get_state() if state: state['controller_channel'] = channel_name state['controller_username'] = username await self._set_state(state) async def broadcast_room_status(self): state = await self._get_state() if state: await self.channel_layer.group_send( self.room_group_name, { 'type': 'room_status_update', 'users': state['users'], 'controller_channel': state['controller_channel'], 'controller_username': state['controller_username'] } )