start multiuser support
This commit is contained in:
@@ -545,6 +545,9 @@
|
|||||||
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||||
data-auto-cache-stack="false"
|
data-auto-cache-stack="false"
|
||||||
data-named-stacks='{{case.get_case_named_stacks}}'
|
data-named-stacks='{{case.get_case_named_stacks}}'
|
||||||
|
data-multiuser-room="case_{{ case.pk }}"
|
||||||
|
data-username="{{ request.user.username }}"
|
||||||
|
data-is-staff="{% if request.user.is_staff %}true{% else %}false{% endif %}"
|
||||||
></div>
|
></div>
|
||||||
</details>
|
</details>
|
||||||
<details class="series-panel" id="case-series-panel" open>
|
<details class="series-panel" id="case-series-panel" open>
|
||||||
|
|||||||
@@ -140,6 +140,9 @@
|
|||||||
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
|
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
|
||||||
data-auto-cache-stack="false"
|
data-auto-cache-stack="false"
|
||||||
data-named-stacks='{{ named_stacks_json }}'
|
data-named-stacks='{{ named_stacks_json }}'
|
||||||
|
data-multiuser-room="case_{{ case.pk }}"
|
||||||
|
data-username="{{ request.user.username }}"
|
||||||
|
data-is-staff="{% if request.user.is_staff %}true{% else %}false{% endif %}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
|
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import logging
|
||||||
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
|
from django.core.cache import cache
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class MultiuserConsumer(AsyncJsonWebsocketConsumer):
|
||||||
|
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')
|
||||||
|
|
||||||
|
# Determine username and staff privilege
|
||||||
|
if self.user and self.user.is_authenticated:
|
||||||
|
self.username = self.user.username
|
||||||
|
self.is_staff = self.user.is_staff
|
||||||
|
else:
|
||||||
|
self.username = 'Anonymous'
|
||||||
|
self.is_staff = False
|
||||||
|
|
||||||
|
# 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
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update room state in cache & notify others
|
||||||
|
await self.add_user_to_room()
|
||||||
|
await self.broadcast_room_status()
|
||||||
|
|
||||||
|
async def disconnect(self, 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
|
||||||
|
await self.remove_user_from_room()
|
||||||
|
await self.broadcast_room_status()
|
||||||
|
|
||||||
|
async def receive_json(self, content):
|
||||||
|
msg_type = content.get('type')
|
||||||
|
|
||||||
|
if msg_type == 'viewer_state':
|
||||||
|
# Replicate view state changes from the active controller to others
|
||||||
|
if await self.has_control():
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'viewer_state_update',
|
||||||
|
'sender': self.channel_name,
|
||||||
|
'data': content.get('data')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif msg_type == 'annotations':
|
||||||
|
# Replicate annotation changes from the active controller to others
|
||||||
|
if await self.has_control():
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'annotations_update',
|
||||||
|
'sender': self.channel_name,
|
||||||
|
'data': content.get('data')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif msg_type == 'request_control':
|
||||||
|
# Broadcast a control request to all participants (so the admin/controller can grant it)
|
||||||
|
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:
|
||||||
|
await self.set_controller(self.channel_name, self.username)
|
||||||
|
await self.broadcast_room_status()
|
||||||
|
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:
|
||||||
|
await self.set_controller(target_channel, target_username)
|
||||||
|
await self.broadcast_room_status()
|
||||||
|
elif msg_type == 'release_control':
|
||||||
|
# Relinquish control
|
||||||
|
if await self.has_control():
|
||||||
|
await self.set_controller(None, None)
|
||||||
|
await self.broadcast_room_status()
|
||||||
|
|
||||||
|
# Channels group event handlers
|
||||||
|
async def viewer_state_update(self, event):
|
||||||
|
if event['sender'] != 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:
|
||||||
|
await self.send_json({
|
||||||
|
'type': 'annotations',
|
||||||
|
'data': event['data']
|
||||||
|
})
|
||||||
|
|
||||||
|
async def control_request(self, event):
|
||||||
|
await self.send_json({
|
||||||
|
'type': 'control_request',
|
||||||
|
'sender_name': event['sender_name'],
|
||||||
|
'sender_channel': event['sender_channel']
|
||||||
|
})
|
||||||
|
|
||||||
|
async def room_status_update(self, event):
|
||||||
|
await self.send_json({
|
||||||
|
'type': 'room_status',
|
||||||
|
'users': event['users'],
|
||||||
|
'controller_channel': event['controller_channel'],
|
||||||
|
'controller_username': event['controller_username']
|
||||||
|
})
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
state['users'][self.channel_name] = {
|
||||||
|
'username': self.username,
|
||||||
|
'is_staff': self.is_staff
|
||||||
|
}
|
||||||
|
|
||||||
|
# Auto-assign control if no active controller exists
|
||||||
|
if not state['controller_channel']:
|
||||||
|
state['controller_channel'] = self.channel_name
|
||||||
|
state['controller_username'] = self.username
|
||||||
|
|
||||||
|
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']
|
||||||
|
else:
|
||||||
|
state['controller_channel'] = None
|
||||||
|
state['controller_username'] = None
|
||||||
|
|
||||||
|
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']
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r'^ws/multiuser/(?P<room_name>\w+)/$', consumers.MultiuserConsumer.as_asgi()),
|
||||||
|
]
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import os
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rad.settings')
|
||||||
|
# Initialize Django ASGI application early to ensure AppRegistry is populated
|
||||||
|
# before importing consumers/routing.
|
||||||
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
from channels.auth import AuthMiddlewareStack
|
||||||
|
import generic.routing
|
||||||
|
|
||||||
|
application = ProtocolTypeRouter({
|
||||||
|
"http": django_asgi_app,
|
||||||
|
"websocket": AuthMiddlewareStack(
|
||||||
|
URLRouter(
|
||||||
|
generic.routing.websocket_urlpatterns
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
@@ -43,6 +43,7 @@ ALLOWED_HOSTS = [
|
|||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
"daphne",
|
||||||
"generic",
|
"generic",
|
||||||
"anatomy",
|
"anatomy",
|
||||||
"physics",
|
"physics",
|
||||||
@@ -74,6 +75,7 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"channels",
|
||||||
"django.contrib.postgres",
|
"django.contrib.postgres",
|
||||||
"dbbackup",
|
"dbbackup",
|
||||||
"tinymce",
|
"tinymce",
|
||||||
@@ -395,6 +397,17 @@ TASKS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ASGI & Django Channels settings
|
||||||
|
ASGI_APPLICATION = "rad.asgi.application"
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||||
|
"CONFIG": {
|
||||||
|
"hosts": [("redis", 6379)],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .settings_local import *
|
from .settings_local import *
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
|
|||||||
+177
-177
File diff suppressed because one or more lines are too long
@@ -60,6 +60,9 @@ requests
|
|||||||
pygments
|
pygments
|
||||||
celery[redis]
|
celery[redis]
|
||||||
redis
|
redis
|
||||||
|
channels==4.1.0
|
||||||
|
daphne==4.1.2
|
||||||
|
channels-redis==4.2.1
|
||||||
pywatchman
|
pywatchman
|
||||||
numpy==2.4.0
|
numpy==2.4.0
|
||||||
django-tasks
|
django-tasks
|
||||||
|
|||||||
Reference in New Issue
Block a user