remove the multiuser sync support
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 6.0.1 on 2026-07-13 10:41
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0110_casecollection_post_survey_mandatory_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='MultiuserSession',
|
||||
),
|
||||
]
|
||||
+1
-10
@@ -3026,13 +3026,4 @@ class PrerequisiteRequired(Exception):
|
||||
super().__init__(message or "Prerequisite required")
|
||||
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}"
|
||||
|
||||
@@ -146,9 +146,8 @@
|
||||
<a class="nav-link d-inline" title="Additional Features"><i class="bi bi-three-dots me-1" aria-hidden="true"></i>Other</a>
|
||||
<a class="nav-link d-inline dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<ul class="dropdown-menu">
|
||||
<a class="dropdown-item" 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="dropdown-item" 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>
|
||||
<a class="dropdown-item" 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="dropdown-item" 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="dropdown-item" href="{% url 'atlas:help' %}" title="View the atlas help pages"><i class="bi bi-question-circle me-1" aria-hidden="true"></i>Help</a>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -545,9 +545,6 @@
|
||||
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||
data-auto-cache-stack="false"
|
||||
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>
|
||||
</details>
|
||||
<details class="series-panel" id="case-series-panel" open>
|
||||
|
||||
@@ -137,13 +137,10 @@
|
||||
<div id="viewer-moveable-home">
|
||||
<div id="viewer-moveable">
|
||||
<div id="viewer-container" class="viewer-resizable-container">
|
||||
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
|
||||
data-auto-cache-stack="false"
|
||||
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 id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
|
||||
data-auto-cache-stack="false"
|
||||
data-named-stacks='{{ named_stacks_json }}'
|
||||
></div>
|
||||
</div>
|
||||
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
|
||||
<div class="viewer-resize-grip-inner"></div>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
{% 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 -->
|
||||
{% if request.user.is_staff %}
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main content: Sessions list -->
|
||||
<div class="{% if request.user.is_staff %}col-md-8{% else %}col-12{% endif %}">
|
||||
{% if request.user.is_staff %}
|
||||
<!-- 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>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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 %}
|
||||
@@ -1,319 +0,0 @@
|
||||
{% 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 flex-wrap 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 me-2">{{ session.id|stringformat:".8s" }}</span>
|
||||
<!-- Debug Mode Toggle -->
|
||||
<div class="form-check form-switch small text-light mb-0 d-flex align-items-center gap-2 ms-md-2">
|
||||
<input class="form-check-input" type="checkbox" id="syncDebugModeCheckbox" onchange="toggleSyncDebugMode(this.checked)">
|
||||
<label class="form-check-label text-muted small" for="syncDebugModeCheckbox"><i class="bi bi-bug-fill text-info me-1"></i>Debug Mode</label>
|
||||
</div>
|
||||
</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" name="q" 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>
|
||||
|
||||
<!-- Live Debug Logs Panel -->
|
||||
<div id="syncDebugLogsPanel" class="card bg-dark border-info text-white mt-4" style="display: none;">
|
||||
<div class="card-header bg-info bg-opacity-25 text-info d-flex justify-content-between align-items-center py-2 px-3">
|
||||
<span class="fw-semibold"><i class="bi bi-terminal me-2"></i>Live Multiuser Sync Logs</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-outline-info btn-xs py-0 px-2 small" style="font-size: 0.75rem;" onclick="clearDebugLogs()">Clear</button>
|
||||
<span class="badge bg-info text-dark font-monospace small">DEBUG MODE ACTIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="syncDebugLogsContainer" class="bg-black text-success font-monospace p-3 small" style="height: 180px; overflow-y: auto; line-height: 1.5; font-size: 0.85rem;">
|
||||
<div class="text-muted">Debug mode activated. Listening for WebSocket messages...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script type="text/javascript">
|
||||
function escapeHtml(text) {
|
||||
if (typeof text !== "string") {
|
||||
try {
|
||||
text = JSON.stringify(text);
|
||||
} catch (e) {
|
||||
text = String(text);
|
||||
}
|
||||
}
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function logDebug(message, data) {
|
||||
console.log("[Multiuser Session]", message, data || "");
|
||||
|
||||
// Append to on-screen console if visible
|
||||
const container = document.getElementById("syncDebugLogsContainer");
|
||||
if (container) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const line = document.createElement("div");
|
||||
line.className = "mb-1 border-bottom border-secondary pb-1 border-opacity-25";
|
||||
line.innerHTML = `<span class="text-muted">[${timestamp}]</span> <strong>${escapeHtml(message)}</strong>`;
|
||||
if (data) {
|
||||
line.innerHTML += ` <span class="text-info">${escapeHtml(data)}</span>`;
|
||||
}
|
||||
container.appendChild(line);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
window.logDebug = logDebug;
|
||||
|
||||
function clearDebugLogs() {
|
||||
const container = document.getElementById("syncDebugLogsContainer");
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="text-muted">Logs cleared.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSyncDebugMode(enabled) {
|
||||
localStorage.setItem("multiuser_sync_debug_mode", enabled ? "true" : "false");
|
||||
|
||||
const panel = document.getElementById("syncDebugLogsPanel");
|
||||
if (panel) {
|
||||
panel.style.display = enabled ? "block" : "none";
|
||||
}
|
||||
|
||||
const checkbox = document.getElementById("syncDebugModeCheckbox");
|
||||
if (checkbox && checkbox.checked !== enabled) {
|
||||
checkbox.checked = enabled;
|
||||
}
|
||||
|
||||
if (typeof window.setDicomViewerLogging === "function") {
|
||||
window.setDicomViewerLogging({ minLevel: enabled ? "debug" : "warn" });
|
||||
logDebug("DV3D viewer log level set to " + (enabled ? "debug" : "warn"));
|
||||
} else {
|
||||
logDebug("DV3D viewer logging control not ready yet (will apply when viewer mounts).");
|
||||
}
|
||||
}
|
||||
|
||||
// Set invite link input on load
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const inviteInput = document.getElementById("inviteLinkInput");
|
||||
if (inviteInput) {
|
||||
inviteInput.value = window.location.href;
|
||||
}
|
||||
|
||||
// Initialize Debug Mode from LocalStorage
|
||||
const debugSaved = localStorage.getItem("multiuser_sync_debug_mode") === "true";
|
||||
toggleSyncDebugMode(debugSaved);
|
||||
|
||||
// Keep attempting to apply log level as viewer mounts
|
||||
const checkInterval = setInterval(function() {
|
||||
if (typeof window.setDicomViewerLogging === "function") {
|
||||
const isDebug = localStorage.getItem("multiuser_sync_debug_mode") === "true";
|
||||
window.setDicomViewerLogging({ minLevel: isDebug ? "debug" : "warn" });
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
}, 200);
|
||||
setTimeout(function() { clearInterval(checkInterval); }, 15000);
|
||||
});
|
||||
|
||||
// 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() {
|
||||
let socket = null;
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = window.location.host || "localhost:8080";
|
||||
const wsUrl = `${protocol}//${host}/ws/multiuser/{{ session.id }}/?client_type=session`;
|
||||
|
||||
logDebug("Connecting session WS to", wsUrl);
|
||||
socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = function() {
|
||||
logDebug("Session WS connected successfully");
|
||||
};
|
||||
|
||||
socket.onmessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
logDebug("Session WS event received (type=" + data.type + ")", data);
|
||||
|
||||
if (data.type === 'change_case') {
|
||||
{% if not is_manager %}
|
||||
logDebug("Session host changed case to " + data.case_id + ". Fetching new case via HTMX...");
|
||||
// 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 %}
|
||||
logDebug("Host ignored change_case broadcast to avoid double-swap");
|
||||
{% endif %}
|
||||
} else if (data.type === 'room_status') {
|
||||
// Update user count
|
||||
const userCount = Object.keys(data.users || {}).length;
|
||||
logDebug("Room status update. Active users: " + userCount, data.users);
|
||||
const mgrCountBadge = document.getElementById("connectedUsersCount");
|
||||
const partCountBadge = document.getElementById("connectedUsersCountParticipant");
|
||||
|
||||
if (mgrCountBadge) mgrCountBadge.textContent = userCount;
|
||||
if (partCountBadge) partCountBadge.textContent = userCount;
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug("Error processing session WS message: " + err.message);
|
||||
console.error("Error processing session WS message", err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = function(e) {
|
||||
logDebug("Session WS closed (code=" + e.code + "). Reconnecting in 3s...");
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
socket.onerror = function(err) {
|
||||
logDebug("Session WS error encountered");
|
||||
console.error("Session WS error:", err);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.animate-spin {
|
||||
animation: spin 8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,74 +0,0 @@
|
||||
<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>
|
||||
@@ -1,28 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +0,0 @@
|
||||
<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>
|
||||
@@ -1,152 +0,0 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.contrib.auth import get_user_model
|
||||
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_staff(client, create_user):
|
||||
user = create_user
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
session1 = MultiuserSession.objects.create(name="User Session 1", creator=user)
|
||||
|
||||
User = get_user_model()
|
||||
other_user = User.objects.create_user(username="other", password="password", email="other@test.com")
|
||||
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_dashboard_lists_sessions_non_staff(client, create_user):
|
||||
user = create_user
|
||||
|
||||
User = get_user_model()
|
||||
other_user = User.objects.create_user(username="other", password="password", email="other@test.com")
|
||||
other_user.is_staff = True
|
||||
other_user.save()
|
||||
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, "Other Session 2")
|
||||
assert "Start New Session" not in response.content.decode("utf-8")
|
||||
assert "Sessions You Managed" not in response.content.decode("utf-8")
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_multiuser_create_session_action(client, create_user):
|
||||
user = create_user
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
client.force_login(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_create_session_action_non_staff_denied(client, create_user):
|
||||
user = create_user
|
||||
client.force_login(user)
|
||||
response = client.post(reverse("atlas:multiuser_create_session"), data={"name": "Denied Session"})
|
||||
assert response.status_code == 302 # Redirected to admin login page because of @staff_member_required
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_multiuser_session_detail_manager_vs_participant(client):
|
||||
User = get_user_model()
|
||||
manager = User.objects.create_user(username="manager", password="password", email="manager@test.com")
|
||||
participant = User.objects.create_user(username="participant", password="password", email="participant@test.com")
|
||||
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):
|
||||
User = get_user_model()
|
||||
manager = User.objects.create_user(username="manager", password="password", email="manager@test.com")
|
||||
participant = User.objects.create_user(username="participant", password="password", email="participant@test.com")
|
||||
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, make_case):
|
||||
User = get_user_model()
|
||||
manager = User.objects.create_user(username="manager", password="password", email="manager@test.com")
|
||||
participant = User.objects.create_user(username="participant", password="password", email="participant@test.com")
|
||||
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)
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
with patch("channels.layers.get_channel_layer") as mock_get_channel_layer:
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.group_send = AsyncMock()
|
||||
mock_get_channel_layer.return_value = mock_layer
|
||||
|
||||
response = client.post(reverse("atlas:multiuser_load_case", kwargs={"session_id": session.id}), data={"case_id": case.pk})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify Channels group_send was called to notify the group about the case change
|
||||
mock_layer.group_send.assert_called_once_with(
|
||||
f"multiuser_{session.id}",
|
||||
{
|
||||
"type": "change_case",
|
||||
"case_id": case.id
|
||||
}
|
||||
)
|
||||
|
||||
session.refresh_from_db()
|
||||
assert session.active_case == case
|
||||
assertContains(response, "Interactive DICOM Viewer")
|
||||
@@ -846,11 +846,4 @@ 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/<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"),
|
||||
]
|
||||
|
||||
-145
@@ -125,7 +125,6 @@ from .models import (
|
||||
CidReportAnswer,
|
||||
Condition,
|
||||
Differential,
|
||||
MultiuserSession,
|
||||
PathologicalProcess,
|
||||
Presentation,
|
||||
QuestionSchema,
|
||||
@@ -11584,147 +11583,3 @@ def collection_case_displaysetup(
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@staff_member_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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
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']
|
||||
}
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'^/?ws/multiuser/(?P<room_name>[\w-]+)/$', consumers.MultiuserConsumer.as_asgi()),
|
||||
]
|
||||
+1
-15
@@ -2,19 +2,5 @@ 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
|
||||
)
|
||||
),
|
||||
})
|
||||
application = get_asgi_application()
|
||||
|
||||
+1
-12
@@ -43,7 +43,6 @@ ALLOWED_HOSTS = [
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"daphne",
|
||||
"generic",
|
||||
"anatomy",
|
||||
"physics",
|
||||
@@ -75,7 +74,6 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"channels",
|
||||
"django.contrib.postgres",
|
||||
"dbbackup",
|
||||
"tinymce",
|
||||
@@ -406,16 +404,7 @@ TASKS = {
|
||||
}
|
||||
|
||||
|
||||
# ASGI & Django Channels settings
|
||||
ASGI_APPLICATION = "rad.asgi.application"
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(REDIS_HOST, 6379)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def show_toolbar(request):
|
||||
if request.headers.get("HX-Request") or getattr(request, "htmx", False):
|
||||
|
||||
+186
-186
File diff suppressed because one or more lines are too long
@@ -60,9 +60,6 @@ requests
|
||||
pygments
|
||||
celery[redis]
|
||||
redis
|
||||
channels==4.1.0
|
||||
daphne==4.2.2
|
||||
channels-redis==4.2.1
|
||||
pywatchman
|
||||
numpy==2.4.0
|
||||
django-tasks
|
||||
|
||||
Reference in New Issue
Block a user