diff --git a/atlas/migrations/0108_multiusersession.py b/atlas/migrations/0108_multiusersession.py
new file mode 100644
index 00000000..e01a5ce2
--- /dev/null
+++ b/atlas/migrations/0108_multiusersession.py
@@ -0,0 +1,27 @@
+# Generated by Django 6.0.1 on 2026-06-16 11:53
+
+import django.db.models.deletion
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('atlas', '0107_caseprior_relation_type'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='MultiuserSession',
+ fields=[
+ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ('name', models.CharField(blank=True, max_length=255)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('active_case', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='atlas.case')),
+ ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_multiuser_sessions', to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/atlas/models.py b/atlas/models.py
index 6737dc51..fb796b48 100644
--- a/atlas/models.py
+++ b/atlas/models.py
@@ -3,6 +3,7 @@ import json
import os
import pathlib
from typing import Tuple
+import uuid
from django.http import Http404, HttpRequest
from generic.mixins import AuthorMixin, QuestionMixin
@@ -879,6 +880,30 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
self._ordered_series_cache = ordered
return ordered
+ def get_case_named_stacks(self):
+ stacks = []
+ for series in self.get_ordered_series():
+ series_images = list(series.get_images())
+ series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
+ images = [url for _, url in series_images_with_urls]
+ declared_groups = _build_declared_series_groups(series, series_images_with_urls)
+ stacks.append(
+ {
+ "name": str(series),
+ "imageIds": images,
+ "seriesId": series.pk,
+ "series": declared_groups,
+ }
+ )
+ results = [
+ {
+ "caseId": f"CASE-{self.pk}",
+ "studyId": "Current Case",
+ "stacks": stacks,
+ }
+ ]
+ return json.dumps(results)
+
@staticmethod
def _parse_dicom_date(value):
if not value:
@@ -2949,4 +2974,15 @@ class PrerequisiteRequired(Exception):
def __init__(self, message=None, *, prereq=None):
super().__init__(message or "Prerequisite required")
- self.prereq = prereq
\ No newline at end of file
+ self.prereq = prereq
+
+
+class MultiuserSession(models.Model):
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ name = models.CharField(max_length=255, blank=True)
+ creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="created_multiuser_sessions")
+ active_case = models.ForeignKey(Case, on_delete=models.SET_NULL, null=True, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ def __str__(self):
+ return f"Session {self.name or str(self.id)[:8]} by {self.creator}"
\ No newline at end of file
diff --git a/atlas/templates/atlas/base.html b/atlas/templates/atlas/base.html
index 40c9fef4..c8974c6f 100755
--- a/atlas/templates/atlas/base.html
+++ b/atlas/templates/atlas/base.html
@@ -145,6 +145,9 @@
Question Schemas
+
+ Multiuser Sync
+
Help
diff --git a/atlas/templates/atlas/multiuser_dashboard.html b/atlas/templates/atlas/multiuser_dashboard.html
new file mode 100644
index 00000000..6e00666f
--- /dev/null
+++ b/atlas/templates/atlas/multiuser_dashboard.html
@@ -0,0 +1,148 @@
+{% extends 'atlas/base.html' %}
+
+{% block title %}
+ Multiuser Sync Portal - Atlas
+{% endblock %}
+
+{% block content %}
+
+
+
+
Multiuser DICOM Portal
+
Create or join real-time collaborative review sessions with synchronized DICOM viewports.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if my_sessions %}
+
+
+
+
+ | Name |
+ Active Case |
+ Created At |
+ Action |
+
+
+
+ {% for session in my_sessions %}
+
+
+ {{ session.name|default:"Unnamed Session" }}
+
+ {{ session.id }}
+ |
+
+ {% if session.active_case %}
+ {{ session.active_case.title|default:"Case Loaded" }}
+ {% else %}
+ No case active
+ {% endif %}
+ |
+ {{ session.created_at|date:"d M Y, H:i" }} |
+
+
+ Enter Workspace
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+ You haven't created any sessions yet.
+
+ {% endif %}
+
+
+
+
+
+
+
+ {% if active_sessions %}
+
+
+
+
+ | Name |
+ Host |
+ Active Case |
+ Created At |
+ Action |
+
+
+
+ {% for session in active_sessions %}
+
+
+ {{ session.name|default:"Unnamed Session" }}
+
+ {{ session.id }}
+ |
+ {{ session.creator.get_full_name|default:session.creator.username }} |
+
+ {% if session.active_case %}
+ {{ session.active_case.title|default:"Case Loaded" }}
+ {% else %}
+ No case active
+ {% endif %}
+ |
+ {{ session.created_at|date:"d M Y, H:i" }} |
+
+
+ Join Session
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+ No other active sessions found. Create a session to invite others!
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/atlas/templates/atlas/multiuser_session_detail.html b/atlas/templates/atlas/multiuser_session_detail.html
new file mode 100644
index 00000000..f2745b13
--- /dev/null
+++ b/atlas/templates/atlas/multiuser_session_detail.html
@@ -0,0 +1,204 @@
+{% extends 'atlas/base.html' %}
+{% load static %}
+{% load django_htmx %}
+
+{% block title %}
+ Multiuser Workspace - {{ session.name|default:"Session" }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+ {{ session.name|default:"Unnamed Session" }}
+
+
{{ session.id|stringformat:".8s" }}
+
+
+ Managed by {{ session.creator.get_full_name|default:session.creator.username }}
+
+
+
+
+
+
+
+ Share Invite
+
+
+
+
+
+
+
+
+ {% if is_manager %}
+
+
+
+ Host Controls:
+
+
+
+
+
+
|
+
+
+
+ Connected Users: 1
+
+
+
+ {% else %}
+
+
+
+
+ You are viewing this session in real-time. Viewport controls are synchronized.
+
+
+ Connected Users: 1
+
+
+
+ {% endif %}
+
+
+
+
+
+ Loading case workspace...
+
+
+
+
+{% endblock %}
+
+{% block js %}
+
+
+
+{% endblock %}
diff --git a/atlas/templates/atlas/partials/_multiuser_case_content.html b/atlas/templates/atlas/partials/_multiuser_case_content.html
new file mode 100644
index 00000000..ee07cfb0
--- /dev/null
+++ b/atlas/templates/atlas/partials/_multiuser_case_content.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
{{ case.title|default:"Untitled Case" }}
+
+
+ {% if case.description %}
+
+
Description
+
{{ case.description }}
+
+ {% endif %}
+
+ {% if case.history %}
+
+
Patient History
+
{{ case.history|linebreaksbr }}
+
+ {% endif %}
+
+ {% if is_manager %}
+ {% if case.discussion %}
+
+
Discussion (Host Only)
+
{{ case.discussion|linebreaksbr }}
+
+ {% endif %}
+ {% if case.report %}
+
+
Model Report (Host Only)
+
{{ case.report|linebreaksbr }}
+
+ {% endif %}
+ {% endif %}
+
+
+
+
+
+
diff --git a/atlas/templates/atlas/partials/_multiuser_case_search_results.html b/atlas/templates/atlas/partials/_multiuser_case_search_results.html
new file mode 100644
index 00000000..c456c0aa
--- /dev/null
+++ b/atlas/templates/atlas/partials/_multiuser_case_search_results.html
@@ -0,0 +1,28 @@
+
diff --git a/atlas/templates/atlas/partials/_multiuser_empty_case.html b/atlas/templates/atlas/partials/_multiuser_empty_case.html
new file mode 100644
index 00000000..0b675b19
--- /dev/null
+++ b/atlas/templates/atlas/partials/_multiuser_empty_case.html
@@ -0,0 +1,12 @@
+
+
+
+ {% if is_manager %}
+
Ready to start review!
+
Search and select a case above to load it into the session.
+ {% else %}
+
Welcome to the session!
+
Waiting for the session host to load a case...
+ {% endif %}
+
+
diff --git a/atlas/tests/test_multiuser.py b/atlas/tests/test_multiuser.py
new file mode 100644
index 00000000..00e0f5be
--- /dev/null
+++ b/atlas/tests/test_multiuser.py
@@ -0,0 +1,103 @@
+import pytest
+from django.urls import reverse
+from atlas.models import Case, MultiuserSession
+from pytest_django.asserts import assertContains, assertRedirects
+
+@pytest.mark.django_db
+def test_multiuser_session_model_creation(create_user):
+ user = create_user
+ session = MultiuserSession.objects.create(
+ name="Test Sync Session",
+ creator=user
+ )
+ assert session.name == "Test Sync Session"
+ assert session.creator == user
+ assert session.active_case is None
+ assert str(session).startswith("Session Test Sync")
+
+@pytest.mark.django_db
+def test_multiuser_dashboard_view_anonymous_redirect(client):
+ response = client.get(reverse("atlas:multiuser_dashboard"))
+ assertRedirects(response, f"{reverse('login')}?next={reverse('atlas:multiuser_dashboard')}")
+
+@pytest.mark.django_db
+def test_multiuser_dashboard_lists_sessions(client, create_user):
+ user = create_user
+ session1 = MultiuserSession.objects.create(name="User Session 1", creator=user)
+
+ other_user = create_user
+ session2 = MultiuserSession.objects.create(name="Other Session 2", creator=other_user)
+
+ client.force_login(user)
+ response = client.get(reverse("atlas:multiuser_dashboard"))
+ assert response.status_code == 200
+ assertContains(response, "User Session 1")
+ assertContains(response, "Other Session 2")
+ assertContains(response, "Start New Session")
+
+@pytest.mark.django_db
+def test_multiuser_create_session_action(client, create_user):
+ client.force_login(create_user)
+ response = client.post(reverse("atlas:multiuser_create_session"), data={"name": "My New Session"})
+
+ session = MultiuserSession.objects.get(name="My New Session")
+ assertRedirects(response, reverse("atlas:multiuser_session_detail", kwargs={"session_id": session.id}))
+
+@pytest.mark.django_db
+def test_multiuser_session_detail_manager_vs_participant(client, create_user):
+ manager = create_user
+ participant = create_user
+ session = MultiuserSession.objects.create(name="Collaboration Session", creator=manager)
+
+ # Manager view
+ client.force_login(manager)
+ response = client.get(reverse("atlas:multiuser_session_detail", kwargs={"session_id": session.id}))
+ assert response.status_code == 200
+ assertContains(response, "Host Controls:")
+ assertContains(response, "Search cases to load...")
+
+ # Participant view
+ client.force_login(participant)
+ response = client.get(reverse("atlas:multiuser_session_detail", kwargs={"session_id": session.id}))
+ assert response.status_code == 200
+ assert "Host Controls:" not in response.content.decode("utf-8")
+ assertContains(response, "You are viewing this session in real-time")
+
+@pytest.mark.django_db
+def test_multiuser_search_cases_permission(client, create_user):
+ manager = create_user
+ participant = create_user
+ session = MultiuserSession.objects.create(name="Search Session", creator=manager)
+
+ # Participant cannot search
+ client.force_login(participant)
+ response = client.get(reverse("atlas:multiuser_search_cases", kwargs={"session_id": session.id}) + "?q=test")
+ assert response.status_code == 403
+
+ # Manager can search
+ client.force_login(manager)
+ response = client.get(reverse("atlas:multiuser_search_cases", kwargs={"session_id": session.id}) + "?q=test")
+ assert response.status_code == 200
+
+@pytest.mark.django_db
+def test_multiuser_load_case_action(client, create_user, make_case):
+ manager = create_user
+ participant = create_user
+ session = MultiuserSession.objects.create(name="Load Case Session", creator=manager)
+
+ case = make_case(title="Multiuser Dicom Case")
+ case.author.add(manager) # Grant access to manager
+
+ # Participant cannot load
+ client.force_login(participant)
+ response = client.post(reverse("atlas:multiuser_load_case", kwargs={"session_id": session.id}), data={"case_id": case.pk})
+ assert response.status_code == 403
+
+ # Manager loads case
+ client.force_login(manager)
+ response = client.post(reverse("atlas:multiuser_load_case", kwargs={"session_id": session.id}), data={"case_id": case.pk})
+ assert response.status_code == 200
+
+ session.refresh_from_db()
+ assert session.active_case == case
+ assertContains(response, "Interactive DICOM Viewer")
diff --git a/atlas/urls.py b/atlas/urls.py
index 416f3b8e..7607cb4e 100755
--- a/atlas/urls.py
+++ b/atlas/urls.py
@@ -840,4 +840,11 @@ urlpatterns = [
views.collection_supervision,
name="collection_supervision",
),
+ # Multiuser DICOM Portal
+ path("multiuser/", views.multiuser_dashboard, name="multiuser_dashboard"),
+ path("multiuser/create/", views.multiuser_create_session, name="multiuser_create_session"),
+ path("multiuser/session//", views.multiuser_session_detail, name="multiuser_session_detail"),
+ path("multiuser/session//case-content/", views.multiuser_case_content_partial, name="multiuser_case_content_partial"),
+ path("multiuser/session//load-case/", views.multiuser_load_case, name="multiuser_load_case"),
+ path("multiuser/session//search-cases/", views.multiuser_search_cases, name="multiuser_search_cases"),
]
diff --git a/atlas/views.py b/atlas/views.py
index efbcf3b1..d8cbf3e1 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -124,6 +124,7 @@ from .models import (
CidReportAnswer,
Condition,
Differential,
+ MultiuserSession,
PathologicalProcess,
Presentation,
QuestionSchema,
@@ -10477,3 +10478,122 @@ def collection_case_displaysetup(request, collection_id, case_number=None, case_
"collection_length": case_count,
})
+
+
+@login_required
+def multiuser_dashboard(request):
+ import datetime
+ from django.utils import timezone
+
+ my_sessions = MultiuserSession.objects.filter(creator=request.user).order_by("-created_at")
+ recent_limit = timezone.now() - datetime.timedelta(hours=24)
+ active_sessions = MultiuserSession.objects.exclude(creator=request.user).filter(created_at__gte=recent_limit).order_by("-created_at")
+
+ return render(request, "atlas/multiuser_dashboard.html", {
+ "my_sessions": my_sessions,
+ "active_sessions": active_sessions,
+ })
+
+
+@login_required
+def multiuser_create_session(request):
+ name = request.POST.get("name", "").strip()
+ session = MultiuserSession.objects.create(
+ name=name,
+ creator=request.user
+ )
+ return redirect("atlas:multiuser_session_detail", session_id=session.id)
+
+
+@login_required
+def multiuser_session_detail(request, session_id):
+ session = get_object_or_404(MultiuserSession, id=session_id)
+ is_manager = (request.user == session.creator)
+
+ return render(request, "atlas/multiuser_session_detail.html", {
+ "session": session,
+ "is_manager": is_manager,
+ "active_case": session.active_case,
+ })
+
+
+@login_required
+def multiuser_search_cases(request, session_id):
+ from django.http import HttpResponseForbidden
+ session = get_object_or_404(MultiuserSession, id=session_id)
+ if request.user != session.creator and not request.user.is_staff:
+ return HttpResponseForbidden("Only the session manager can search cases.")
+
+ q = request.GET.get("q", "").strip()
+ from .helpers import get_cases_available_to_user
+ qs = get_cases_available_to_user(request.user)
+ if q:
+ qs = qs.filter(title__icontains=q)
+
+ cases = qs.order_by("title")[:15]
+ return render(request, "atlas/partials/_multiuser_case_search_results.html", {
+ "cases": cases,
+ "session": session,
+ })
+
+
+@login_required
+def multiuser_case_content_partial(request, session_id):
+ session = get_object_or_404(MultiuserSession, id=session_id)
+ case = session.active_case
+
+ if not case:
+ return render(request, "atlas/partials/_multiuser_empty_case.html", {
+ "session": session,
+ "is_manager": (request.user == session.creator),
+ })
+
+ named_stacks_json = case.get_case_named_stacks()
+
+ return render(request, "atlas/partials/_multiuser_case_content.html", {
+ "session": session,
+ "case": case,
+ "named_stacks_json": named_stacks_json,
+ "is_manager": (request.user == session.creator),
+ })
+
+
+@login_required
+@require_POST
+def multiuser_load_case(request, session_id):
+ from django.http import HttpResponseForbidden
+ session = get_object_or_404(MultiuserSession, id=session_id)
+ if request.user != session.creator and not request.user.is_staff:
+ return HttpResponseForbidden("Only the session manager can load cases.")
+
+ case_id = request.POST.get("case_id")
+ if not case_id:
+ return HttpResponse("No case selected", status=400)
+
+ from .helpers import get_cases_available_to_user
+ case = get_object_or_404(get_cases_available_to_user(request.user), pk=case_id)
+
+ session.active_case = case
+ session.save()
+
+ # Broadcast to channels group
+ from channels.layers import get_channel_layer
+ from asgiref.sync import async_to_sync
+
+ channel_layer = get_channel_layer()
+ if channel_layer:
+ async_to_sync(channel_layer.group_send)(
+ f"multiuser_{session.id}",
+ {
+ "type": "change_case",
+ "case_id": case.id
+ }
+ )
+
+ named_stacks_json = case.get_case_named_stacks()
+ return render(request, "atlas/partials/_multiuser_case_content.html", {
+ "session": session,
+ "case": case,
+ "named_stacks_json": named_stacks_json,
+ "is_manager": True,
+ })
diff --git a/generic/consumers.py b/generic/consumers.py
index ff09ec4a..445afa9d 100644
--- a/generic/consumers.py
+++ b/generic/consumers.py
@@ -2,21 +2,39 @@ import logging
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
logger = logging.getLogger(__name__)
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:
+ # Reject connection if session does not exist
+ await self.close()
+ return
+
# 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
+ # Manager is the creator or a site admin/staff
+ self.is_staff = (self.user == session.creator) or self.user.is_staff
else:
- self.username = 'Anonymous'
+ self.username = f"Guest_{self.channel_name[-4:]}"
self.is_staff = False
# Add to channels group
@@ -129,6 +147,12 @@ class MultiuserConsumer(AsyncJsonWebsocketConsumer):
'controller_username': event['controller_username']
})
+ async def change_case(self, event):
+ 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}"
diff --git a/generic/routing.py b/generic/routing.py
index 57a3b762..4a4d08b1 100644
--- a/generic/routing.py
+++ b/generic/routing.py
@@ -2,5 +2,5 @@ from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
- re_path(r'^ws/multiuser/(?P\w+)/$', consumers.MultiuserConsumer.as_asgi()),
+ re_path(r'^ws/multiuser/(?P[\w-]+)/$', consumers.MultiuserConsumer.as_asgi()),
]