add more multiuser support

This commit is contained in:
Ross
2026-06-16 13:14:24 +01:00
parent 6ebe74d785
commit dbb762bb09
13 changed files with 790 additions and 4 deletions
+27
View File
@@ -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)),
],
),
]
+36
View File
@@ -3,6 +3,7 @@ import json
import os import os
import pathlib import pathlib
from typing import Tuple from typing import Tuple
import uuid
from django.http import Http404, HttpRequest from django.http import Http404, HttpRequest
from generic.mixins import AuthorMixin, QuestionMixin from generic.mixins import AuthorMixin, QuestionMixin
@@ -879,6 +880,30 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
self._ordered_series_cache = ordered self._ordered_series_cache = ordered
return 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 @staticmethod
def _parse_dicom_date(value): def _parse_dicom_date(value):
if not value: if not value:
@@ -2950,3 +2975,14 @@ class PrerequisiteRequired(Exception):
def __init__(self, message=None, *, prereq=None): def __init__(self, message=None, *, prereq=None):
super().__init__(message or "Prerequisite required") super().__init__(message or "Prerequisite required")
self.prereq = prereq 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}"
+3
View File
@@ -145,6 +145,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a> <a class="nav-link" href="{% url 'atlas:question_schema_overview' %}" title="View and edit question schemas"><i class="bi bi-journal-text me-1" aria-hidden="true"></i>Question Schemas</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{% url 'atlas:multiuser_dashboard' %}" title="Multiuser case sync and review"><i class="bi bi-people me-1" aria-hidden="true"></i>Multiuser Sync</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a> <a class="nav-link" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
</li> </li>
@@ -0,0 +1,148 @@
{% extends 'atlas/base.html' %}
{% block title %}
Multiuser Sync Portal - Atlas
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row mb-4 align-items-center">
<div class="col">
<h1 class="h3 mb-1"><i class="bi bi-people-fill text-primary me-2"></i>Multiuser DICOM Portal</h1>
<p class="text-muted">Create or join real-time collaborative review sessions with synchronized DICOM viewports.</p>
</div>
</div>
<div class="row">
<!-- Sidebar: Start a new session -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-secondary">
<div class="card-header bg-dark text-white fw-bold">
<i class="bi bi-plus-circle me-1"></i> Start New Session
</div>
<div class="card-body">
<form method="POST" action="{% url 'atlas:multiuser_create_session' %}">
{% csrf_token %}
<div class="mb-3">
<label for="sessionName" class="form-label">Session Name / Topic (Optional)</label>
<input type="text" class="form-control bg-dark text-white border-secondary" id="sessionName" name="name" placeholder="e.g. Case Review Cohort A" max_length="255">
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-play-fill me-1"></i> Start Session
</button>
</form>
</div>
</div>
</div>
<!-- Main content: Sessions list -->
<div class="col-md-8">
<!-- My Sessions -->
<div class="card shadow-sm mb-4 bg-dark text-white border-secondary">
<div class="card-header bg-secondary text-white fw-bold">
<i class="bi bi-person-fill-check me-1"></i> Sessions You Managed
</div>
<div class="card-body p-0">
{% if my_sessions %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover mb-0 align-middle">
<thead>
<tr>
<th>Name</th>
<th>Active Case</th>
<th>Created At</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
{% for session in my_sessions %}
<tr>
<td>
<strong class="text-info">{{ session.name|default:"Unnamed Session" }}</strong>
<br>
<small class="text-muted font-monospace">{{ session.id }}</small>
</td>
<td>
{% if session.active_case %}
<span class="badge bg-success">{{ session.active_case.title|default:"Case Loaded" }}</span>
{% else %}
<span class="badge bg-secondary">No case active</span>
{% endif %}
</td>
<td>{{ session.created_at|date:"d M Y, H:i" }}</td>
<td class="text-end">
<a href="{% url 'atlas:multiuser_session_detail' session_id=session.id %}" class="btn btn-outline-info btn-sm">
Enter Workspace <i class="bi bi-arrow-right-short"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-inbox-fill fs-2 mb-2 d-block"></i>
You haven't created any sessions yet.
</div>
{% endif %}
</div>
</div>
<!-- Active public sessions -->
<div class="card shadow-sm bg-dark text-white border-secondary">
<div class="card-header bg-secondary text-white fw-bold">
<i class="bi bi-globe me-1"></i> Active Invited Sessions (Last 24 Hours)
</div>
<div class="card-body p-0">
{% if active_sessions %}
<div class="table-responsive">
<table class="table table-dark table-striped table-hover mb-0 align-middle">
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>Active Case</th>
<th>Created At</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
{% for session in active_sessions %}
<tr>
<td>
<strong class="text-info">{{ session.name|default:"Unnamed Session" }}</strong>
<br>
<small class="text-muted font-monospace">{{ session.id }}</small>
</td>
<td>{{ session.creator.get_full_name|default:session.creator.username }}</td>
<td>
{% if session.active_case %}
<span class="badge bg-success">{{ session.active_case.title|default:"Case Loaded" }}</span>
{% else %}
<span class="badge bg-secondary">No case active</span>
{% endif %}
</td>
<td>{{ session.created_at|date:"d M Y, H:i" }}</td>
<td class="text-end">
<a href="{% url 'atlas:multiuser_session_detail' session_id=session.id %}" class="btn btn-outline-info btn-sm">
Join Session <i class="bi bi-arrow-right-short"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-broadcast fs-2 mb-2 d-block"></i>
No other active sessions found. Create a session to invite others!
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,204 @@
{% extends 'atlas/base.html' %}
{% load static %}
{% load django_htmx %}
{% block title %}
Multiuser Workspace - {{ session.name|default:"Session" }}
{% endblock %}
{% block content %}
<div class="container-fluid py-3 px-4">
<!-- Session Header -->
<div class="row align-items-center mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-2">
<a href="{% url 'atlas:multiuser_dashboard' %}" class="btn btn-outline-secondary btn-sm" title="Back to Dashboard">
<i class="bi bi-arrow-left"></i>
</a>
<h1 class="h4 mb-0 text-white">
<i class="bi bi-people text-info me-2"></i>{{ session.name|default:"Unnamed Session" }}
</h1>
<span class="badge bg-secondary font-monospace small">{{ session.id|stringformat:".8s" }}</span>
</div>
<div class="small text-muted mt-1">
Managed by <strong>{{ session.creator.get_full_name|default:session.creator.username }}</strong>
</div>
</div>
<!-- Invite Sharing Link Widget -->
<div class="col-md-6 mt-2 mt-md-0 d-flex justify-content-md-end">
<div class="input-group input-group-sm" style="max-width: 420px;">
<span class="input-group-text bg-secondary text-white border-secondary">
<i class="bi bi-link-45deg"></i> Share Invite
</span>
<input type="text" id="inviteLinkInput" class="form-control bg-dark text-white border-secondary" readonly value="">
<button class="btn btn-info" type="button" id="copyLinkBtn" onclick="copyInviteLink()">
<i class="bi bi-clipboard me-1"></i> Copy
</button>
</div>
</div>
</div>
<!-- Manager Control Bar -->
{% if is_manager %}
<div class="card bg-dark border-info text-white mb-4">
<div class="card-body py-2 px-3 d-flex align-items-center flex-wrap gap-3">
<div class="fw-semibold text-info small uppercase me-2">
<i class="bi bi-gear-fill me-1 animate-spin"></i> Host Controls:
</div>
<!-- Search Cases input -->
<div class="position-relative flex-grow-1" style="max-width: 480px;">
<div class="input-group input-group-sm">
<span class="input-group-text bg-secondary text-white border-secondary">
<i class="bi bi-search"></i>
</span>
<input type="text" id="multiuserCaseSearch" class="form-control bg-dark text-white border-secondary"
placeholder="Search cases to load..."
hx-get="{% url 'atlas:multiuser_search_cases' session_id=session.id %}"
hx-trigger="input delay:300ms, focus"
hx-target="#caseSearchDropdown"
hx-swap="innerHTML"
onfocus="document.getElementById('caseSearchDropdown').style.display='block'"
autocomplete="off">
</div>
<!-- Autocomplete Dropdown list -->
<div id="caseSearchDropdown" class="position-absolute w-100 mt-1" style="display: none; z-index: 10000;">
<!-- Results injected by HTMX -->
</div>
</div>
<span class="text-muted small">|</span>
<!-- Users Counter -->
<div class="small text-light">
<i class="bi bi-person-fill text-success"></i> Connected Users: <span id="connectedUsersCount" class="badge bg-secondary">1</span>
</div>
</div>
</div>
{% else %}
<!-- Participant Status Bar -->
<div class="card bg-dark border-secondary text-white mb-4">
<div class="card-body py-2 px-3 d-flex align-items-center justify-content-between">
<div class="small text-muted">
<i class="bi bi-broadcast text-warning me-1"></i> You are viewing this session in real-time. Viewport controls are synchronized.
</div>
<div class="small text-light">
<i class="bi bi-person-fill text-success"></i> Connected Users: <span id="connectedUsersCountParticipant" class="badge bg-secondary">1</span>
</div>
</div>
</div>
{% endif %}
<!-- Main Workspace Area -->
<div id="multiuser-case-container"
hx-get="{% url 'atlas:multiuser_case_content_partial' session_id=session.id %}"
hx-trigger="load"
hx-swap="innerHTML"
>
<div class="d-flex justify-content-center py-5">
<div class="spinner-border text-info" role="status">
<span class="visually-hidden">Loading case workspace...</span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script type="text/javascript">
// Set invite link input on load
document.addEventListener("DOMContentLoaded", function() {
const inviteInput = document.getElementById("inviteLinkInput");
if (inviteInput) {
inviteInput.value = window.location.href;
}
});
// Copy to clipboard helper
function copyInviteLink() {
const inviteInput = document.getElementById("inviteLinkInput");
const copyBtn = document.getElementById("copyLinkBtn");
inviteInput.select();
inviteInput.setSelectionRange(0, 99999); // For mobile devices
navigator.clipboard.writeText(inviteInput.value).then(function() {
const origHTML = copyBtn.innerHTML;
copyBtn.innerHTML = '<i class="bi bi-check-lg me-1"></i> Copied!';
copyBtn.className = "btn btn-success";
setTimeout(function() {
copyBtn.innerHTML = origHTML;
copyBtn.className = "btn btn-info";
}, 2000);
}).catch(function(err) {
console.error('Could not copy link: ', err);
});
}
// Hide search autocomplete dropdown when clicking outside
document.addEventListener("click", function(e) {
const dropdown = document.getElementById("caseSearchDropdown");
const searchInput = document.getElementById("multiuserCaseSearch");
if (dropdown && searchInput && !dropdown.contains(e.target) && e.target !== searchInput) {
dropdown.style.display = "none";
}
});
// Session-level WebSocket to listen for control signals
(function() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host || "localhost:8080";
const wsUrl = `${protocol}//${host}/ws/multiuser/{{ session.id }}/`;
console.log("Connecting session WS to", wsUrl);
const socket = new WebSocket(wsUrl);
socket.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
console.log("Session WS event:", data.type, data);
if (data.type === 'change_case') {
console.log("Session host changed case to", data.case_id);
// Fetch new case content via HTMX
htmx.ajax('GET', "{% url 'atlas:multiuser_case_content_partial' session_id=session.id %}", {
target: "#multiuser-case-container",
swap: "innerHTML"
});
} else if (data.type === 'room_status') {
// Update user count
const userCount = Object.keys(data.users || {}).length;
const mgrCountBadge = document.getElementById("connectedUsersCount");
const partCountBadge = document.getElementById("connectedUsersCountParticipant");
if (mgrCountBadge) mgrCountBadge.textContent = userCount;
if (partCountBadge) partCountBadge.textContent = userCount;
}
} catch (err) {
console.error("Error processing session WS message", err);
}
};
socket.onclose = function(e) {
console.warn("Session WS closed. Reconnecting in 3s...", e);
setTimeout(arguments.callee, 3000);
};
socket.onerror = function(err) {
console.error("Session WS error:", err);
};
})();
</script>
<style>
.animate-spin {
animation: spin 8s linear infinite;
display: inline-block;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
{% endblock %}
@@ -0,0 +1,74 @@
<div class="row">
<!-- Left column: DICOM Viewer -->
<div class="col-lg-9 mb-4">
<div class="card bg-dark border-secondary">
<div class="card-header bg-secondary text-white d-flex align-items-center justify-content-between py-1 px-3">
<span class="fw-semibold"><i class="bi bi-display me-1"></i> Interactive DICOM Viewer</span>
<span class="badge bg-info">Case: {{ case.title|default:"Untitled Case" }}</span>
</div>
<div class="card-body p-0">
<div id="viewer-container" class="viewer-resizable-container" style="height: 70vh; min-height: 500px; max-height: 900px; position: relative; overflow: hidden; width: 100%;">
<div id="main_viewer" class="dicom-viewer-root"
data-auto-cache-stack="false"
data-named-stacks='{{ named_stacks_json }}'
data-multiuser-room="{{ session.id }}"
data-username="{{ request.user.username }}"
data-is-staff="{% if is_manager %}true{% else %}false{% endif %}"
style="width: 100%; height: 100%;"
></div>
</div>
</div>
</div>
</div>
<!-- Right column: Case Details -->
<div class="col-lg-3">
<div class="card bg-dark border-secondary text-white shadow-sm mb-4">
<div class="card-header bg-secondary text-white fw-bold">
<i class="bi bi-info-circle-fill me-1"></i> Case Information
</div>
<div class="card-body">
<h5 class="text-info mb-2">{{ case.title|default:"Untitled Case" }}</h5>
<hr class="border-secondary mt-0">
{% if case.description %}
<div class="mb-3">
<h6 class="text-muted mb-1 small uppercase fw-bold">Description</h6>
<p class="mb-0 small">{{ case.description }}</p>
</div>
{% endif %}
{% if case.history %}
<div class="mb-3">
<h6 class="text-muted mb-1 small uppercase fw-bold">Patient History</h6>
<p class="mb-0 small">{{ case.history|linebreaksbr }}</p>
</div>
{% endif %}
{% if is_manager %}
{% if case.discussion %}
<div class="mb-3">
<h6 class="text-warning mb-1 small uppercase fw-bold">Discussion (Host Only)</h6>
<p class="mb-0 small text-light-emphasis">{{ case.discussion|linebreaksbr }}</p>
</div>
{% endif %}
{% if case.report %}
<div class="mb-3">
<h6 class="text-warning mb-1 small uppercase fw-bold">Model Report (Host Only)</h6>
<p class="mb-0 small text-light-emphasis">{{ case.report|linebreaksbr }}</p>
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
<script type="text/javascript">
if (typeof window.remountDicomViewer === "function") {
console.debug("Remounting DICOM viewer for case change");
window.remountDicomViewer('main_viewer');
} else {
console.warn("window.remountDicomViewer is not defined!");
}
</script>
@@ -0,0 +1,28 @@
<ul class="list-group bg-dark border-secondary shadow-lg" style="max-height: 300px; overflow-y: auto;">
{% for case in cases %}
<li class="list-group-item list-group-item-action bg-dark text-white border-secondary d-flex justify-content-between align-items-center py-2 px-3">
<div class="me-2 text-truncate">
<strong class="text-info d-block text-truncate">{{ case.title|default:"Untitled Case" }}</strong>
<span class="text-muted small">
Authors:
{% for author in case.author.all %}
{{ author.username }}{% if not forloop.last %}, {% endif %}
{% empty %}
Unknown
{% endfor %}
</span>
</div>
<button class="btn btn-primary btn-sm flex-shrink-0"
hx-post="{% url 'atlas:multiuser_load_case' session_id=session.id %}"
hx-vals='{"case_id": "{{ case.pk }}"}'
hx-target="#multiuser-case-container"
hx-swap="innerHTML"
onclick="document.getElementById('caseSearchDropdown').style.display='none'; document.getElementById('multiuserCaseSearch').value='';"
>
Load Case
</button>
</li>
{% empty %}
<li class="list-group-item bg-dark text-muted border-secondary py-2 px-3">No matching cases found</li>
{% endfor %}
</ul>
@@ -0,0 +1,12 @@
<div class="card bg-dark border-secondary text-white text-center py-5 shadow-sm">
<div class="card-body py-5">
<i class="bi bi-display fs-1 text-muted mb-3 d-block"></i>
{% if is_manager %}
<h4 class="text-info">Ready to start review!</h4>
<p class="text-muted mb-4">Search and select a case above to load it into the session.</p>
{% else %}
<h4 class="text-info">Welcome to the session!</h4>
<p class="text-muted">Waiting for the session host to load a case...</p>
{% endif %}
</div>
</div>
+103
View File
@@ -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")
+7
View File
@@ -840,4 +840,11 @@ urlpatterns = [
views.collection_supervision, views.collection_supervision,
name="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/<uuid:session_id>/", views.multiuser_session_detail, name="multiuser_session_detail"),
path("multiuser/session/<uuid:session_id>/case-content/", views.multiuser_case_content_partial, name="multiuser_case_content_partial"),
path("multiuser/session/<uuid:session_id>/load-case/", views.multiuser_load_case, name="multiuser_load_case"),
path("multiuser/session/<uuid:session_id>/search-cases/", views.multiuser_search_cases, name="multiuser_search_cases"),
] ]
+120
View File
@@ -124,6 +124,7 @@ from .models import (
CidReportAnswer, CidReportAnswer,
Condition, Condition,
Differential, Differential,
MultiuserSession,
PathologicalProcess, PathologicalProcess,
Presentation, Presentation,
QuestionSchema, QuestionSchema,
@@ -10477,3 +10478,122 @@ def collection_case_displaysetup(request, collection_id, case_number=None, case_
"collection_length": case_count, "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,
})
+26 -2
View File
@@ -2,21 +2,39 @@ import logging
from channels.generic.websocket import AsyncJsonWebsocketConsumer from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.core.cache import cache from django.core.cache import cache
from asgiref.sync import sync_to_async 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__) logger = logging.getLogger(__name__)
class MultiuserConsumer(AsyncJsonWebsocketConsumer): 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): async def connect(self):
self.room_name = self.scope['url_route']['kwargs']['room_name'] self.room_name = self.scope['url_route']['kwargs']['room_name']
self.room_group_name = f'multiuser_{self.room_name}' self.room_group_name = f'multiuser_{self.room_name}'
self.user = self.scope.get('user') 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 # Determine username and staff privilege
if self.user and self.user.is_authenticated: if self.user and self.user.is_authenticated:
self.username = self.user.username 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: else:
self.username = 'Anonymous' self.username = f"Guest_{self.channel_name[-4:]}"
self.is_staff = False self.is_staff = False
# Add to channels group # Add to channels group
@@ -129,6 +147,12 @@ class MultiuserConsumer(AsyncJsonWebsocketConsumer):
'controller_username': event['controller_username'] '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) # Cache state helpers (using sync_to_async for thread-safe Memcached/Redis interactions)
def get_cache_key(self): def get_cache_key(self):
return f"multiuser_room_{self.room_name}" return f"multiuser_room_{self.room_name}"
+1 -1
View File
@@ -2,5 +2,5 @@ from django.urls import re_path
from . import consumers from . import consumers
websocket_urlpatterns = [ websocket_urlpatterns = [
re_path(r'^ws/multiuser/(?P<room_name>\w+)/$', consumers.MultiuserConsumer.as_asgi()), re_path(r'^ws/multiuser/(?P<room_name>[\w-]+)/$', consumers.MultiuserConsumer.as_asgi()),
] ]