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)),
],
),
]
+37 -1
View File
@@ -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
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">
<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 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">
<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>
@@ -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,
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,
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,
})
+26 -2
View File
@@ -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}"
+1 -1
View File
@@ -2,5 +2,5 @@ from django.urls import re_path
from . import consumers
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()),
]