From dbb762bb09adb07ccbcd82517f274b1a1a40cc17 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 16 Jun 2026 13:14:24 +0100 Subject: [PATCH] add more multiuser support --- atlas/migrations/0108_multiusersession.py | 27 +++ atlas/models.py | 38 +++- atlas/templates/atlas/base.html | 3 + .../templates/atlas/multiuser_dashboard.html | 148 +++++++++++++ .../atlas/multiuser_session_detail.html | 204 ++++++++++++++++++ .../partials/_multiuser_case_content.html | 74 +++++++ .../_multiuser_case_search_results.html | 28 +++ .../atlas/partials/_multiuser_empty_case.html | 12 ++ atlas/tests/test_multiuser.py | 103 +++++++++ atlas/urls.py | 7 + atlas/views.py | 120 +++++++++++ generic/consumers.py | 28 ++- generic/routing.py | 2 +- 13 files changed, 790 insertions(+), 4 deletions(-) create mode 100644 atlas/migrations/0108_multiusersession.py create mode 100644 atlas/templates/atlas/multiuser_dashboard.html create mode 100644 atlas/templates/atlas/multiuser_session_detail.html create mode 100644 atlas/templates/atlas/partials/_multiuser_case_content.html create mode 100644 atlas/templates/atlas/partials/_multiuser_case_search_results.html create mode 100644 atlas/templates/atlas/partials/_multiuser_empty_case.html create mode 100644 atlas/tests/test_multiuser.py 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 @@ + 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.

+
+
+ +
+ +
+
+
+ Start New Session +
+
+
+ {% csrf_token %} +
+ + +
+ +
+
+
+
+ + +
+ +
+
+ Sessions You Managed +
+
+ {% if my_sessions %} +
+ + + + + + + + + + + {% for session in my_sessions %} + + + + + + + {% endfor %} + +
NameActive CaseCreated AtAction
+ {{ 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 + +
+
+ {% else %} +
+ + You haven't created any sessions yet. +
+ {% endif %} +
+
+ + +
+
+ Active Invited Sessions (Last 24 Hours) +
+
+ {% if active_sessions %} +
+ + + + + + + + + + + + {% for session in active_sessions %} + + + + + + + + {% endfor %} + +
NameHostActive CaseCreated AtAction
+ {{ 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 + +
+
+ {% 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 @@ +
+ +
+
+
+ Interactive DICOM Viewer + Case: {{ case.title|default:"Untitled Case" }} +
+
+
+
+
+
+
+
+ + +
+
+
+ Case Information +
+
+
{{ 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()), ]