i should do these more often

This commit is contained in:
Ross
2025-06-23 09:25:48 +01:00
parent 820d0bc030
commit 2f8601aa1a
15 changed files with 637 additions and 188 deletions
+28 -30
View File
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from ninja import ModelSchema, Router, Schema, Field from ninja import ModelSchema, Router, Schema, Field
from django.db import transaction from django.db import transaction, IntegrityError
# from .decorators import user_is_author_or_rapid_checker # from .decorators import user_is_author_or_rapid_checker
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
@@ -109,7 +109,6 @@ def clear_dicoms(request):
dicoms = UncategorisedDicom.objects.filter(user=request.user) dicoms = UncategorisedDicom.objects.filter(user=request.user)
dicoms.delete() dicoms.delete()
print(dicoms)
return True return True
@@ -163,28 +162,31 @@ def import_dicoms_helper(request, case_id: int | None = None):
tags = data[series_uid][0][1] tags = data[series_uid][0][1]
# Check if series with the id already exists (in which case we just add to htat) try:
if Series.objects.filter( with transaction.atomic():
series_instance_uid=tags["SeriesInstanceUID"] series, created = Series.objects.get_or_create(
).exists(): series_instance_uid=tags["SeriesInstanceUID"],
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"]) defaults={
else: "modality": modality,
series = Series( "description": tags["SeriesDescription"],
modality=modality, }
description=tags["SeriesDescription"],
series_instance_uid=tags["SeriesInstanceUID"],
)
if tags["StudyDescription"]:
examination, created = Examination.objects.get_or_create(
examination=tags["StudyDescription"]
) )
if created: if created and tags["StudyDescription"]:
examination.modality = modality examination, created_exam = Examination.objects.get_or_create(
examination.save() examination=tags["StudyDescription"]
series.examination = examination )
if created_exam:
examination.modality = modality
examination.save()
series.examination = examination
series.save()
except IntegrityError:
# If a race condition still occurs, fetch the existing series
pass
series.save()
if 'series' not in locals():
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
# We might only want to add the author during creation.... # We might only want to add the author during creation....
@@ -192,8 +194,11 @@ def import_dicoms_helper(request, case_id: int | None = None):
if case_id is not None: if case_id is not None:
case_object = get_object_or_404(Case, pk=case_id) case_object = get_object_or_404(Case, pk=case_id)
if not series.case.contains(case_object): try:
series.case.add(case_object) series.case.add(case_object)
except IntegrityError:
# The relation already exists, safe to ignore
pass
for dicom, dicom_tags in data[series_uid]: for dicom, dicom_tags in data[series_uid]:
series_image = SeriesImage( series_image = SeriesImage(
@@ -238,7 +243,6 @@ def import_dicoms_case(request, case_id: int):
@router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema]) @router.get("/orphan_series", auth=django_auth, response=List[SeriesSchema])
def orphan_series(request): def orphan_series(request):
# print(request.user.series.filter(case=None))
return request.user.series.filter(case=None) return request.user.series.filter(case=None)
@@ -261,7 +265,6 @@ def series_remove_duplicate_images(request, series_id: int):
@router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth) @router.get("/series_truncate/{series_id}/{start}/{end}/", auth=django_auth)
def series_truncate(request, series_id: int, start: int, end: int): def series_truncate(request, series_id: int, start: int, end: int):
print(start, end)
series = get_object_or_404(Series, pk=series_id) series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user): if not series.check_user_can_edit(request.user):
@@ -272,7 +275,6 @@ def series_truncate(request, series_id: int, start: int, end: int):
if n >= start and n <= end: if n >= start and n <= end:
continue continue
else: else:
print(n, image)
image.removed = True image.removed = True
image.image = None image.image = None
image.save() image.save()
@@ -291,7 +293,6 @@ def get_cases_user(request):
@router.get("/check_image_hash/{hash}", auth=django_auth) @router.get("/check_image_hash/{hash}", auth=django_auth)
def check_image_hash(request, hash: str): def check_image_hash(request, hash: str):
try: try:
print(hash)
series_image = SeriesImage.objects.get(image_blake3_hash=hash) series_image = SeriesImage.objects.get(image_blake3_hash=hash)
data = { data = {
"status": "success", "status": "success",
@@ -372,11 +373,8 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
# Split teh associated images # Split teh associated images
for image in series.images.all(): for image in series.images.all():
print(image)
ds = image.get_dicom_data() ds = image.get_dicom_data()
# print("2a", ds[dicom_tag])
if dicom_tag in ds: if dicom_tag in ds:
val = ds[dicom_tag].value val = ds[dicom_tag].value
+2
View File
@@ -80,6 +80,8 @@ def user_is_collection_author_or_atlas_editor(function):
def wrap(request, *args, **kwargs): def wrap(request, *args, **kwargs):
if "exam_id" in kwargs: if "exam_id" in kwargs:
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"]) atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
elif "collection_id" in kwargs:
atlas = CaseCollection.objects.get(pk=kwargs["collection_id"])
else: else:
atlas = CaseCollection.objects.get(pk=kwargs["pk"]) atlas = CaseCollection.objects.get(pk=kwargs["pk"])
if ( if (
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-06-16 12:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0070_seriesfinding_annotation_json_3d_and_more'),
]
operations = [
migrations.AddField(
model_name='casedetail',
name='default_viewerstate',
field=models.JSONField(blank=True, help_text='Default viewer state for the case', null=True),
),
]
+22 -2
View File
@@ -541,10 +541,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return True return True
return False return False
def get_series_images_nested(self, as_json=True):
"""
Returns a list of lists, where each inner list contains the image URLs
for a single series in this case, in the order of the series.
"""
images = [
[image.image.url for image in series.images.all()]
for series in self.series.all()
]
if as_json:
return json.dumps(images)
else:
return images
def extract_image_dicom_json_from_ds(ds, url, image_index): def extract_image_dicom_json_from_ds(ds, url, image_index):
@@ -1107,6 +1118,12 @@ class CaseDetail(models.Model):
# TODO add feedback for questions # TODO add feedback for questions
#question_feedback = models.JSONField(null=True, blank=True) #question_feedback = models.JSONField(null=True, blank=True)
default_viewerstate = models.JSONField(
null=True,
blank=True,
help_text="Default viewer state for the case"
)
sort_order = models.IntegerField(default=1000) sort_order = models.IntegerField(default=1000)
class Meta: class Meta:
@@ -1129,6 +1146,9 @@ class CaseDetail(models.Model):
except UserReportAnswer.DoesNotExist: except UserReportAnswer.DoesNotExist:
return None return None
def default_viewerstate_string(self):
return json.dumps(self.default_viewerstate) if self.default_viewerstate else ""
class CasePrior(models.Model): class CasePrior(models.Model):
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE) case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case") prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
@@ -440,8 +440,7 @@
}, 200); }, 200);
document.getElementById("reload-finding").addEventListener("click", function () { document.getElementById("reload-finding").addEventListener("click", function () {
importAnnotations_root(annotationjson3d) window.mountDicomViewers();
importViewerState_root(viewerState);
}); });
// Initialize the DICOM viewer with the images and annotations // Initialize the DICOM viewer with the images and annotations
@@ -0,0 +1,68 @@
{% extends 'atlas/exams.html' %}
{% block content %}
<h2>Setup default display for case</h2>
<div id="root" class="dicom-viewer-root" data-images="{{case_detail.case.get_series_images_nested}}"
style="box-sizing: border-box; background: #222; width: 100vw; height: 800px;"
data-auto-cache-stack="true"
{% if case_detail.default_viewerstate %}
data-viewerstate={{ case_detail.default_viewerstate }}
{% endif %}
></div>
<button id="save-viewerstate" type="button" class="btn btn-primary mt-3">
Save Viewer State
</button>
<button id="reset-viewerstate" type="button" class="btn btn-secondary mt-3 ms-2">
Reset to Default
</button>
<div id="viewerstate-save-response" class="mt-2"></div>
<script>
document.getElementById('save-viewerstate').addEventListener('click', function() {
let viewerState = window.exportViewerState_root();
if (!viewerState) {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
return;
}
fetch("{% url 'atlas:collection_case_display_setup' case_detail.collection.pk case_detail.case.pk %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({viewerstate: viewerState})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error saving viewer state.</span>";
});
});
document.getElementById('reset-viewerstate').addEventListener('click', function() {
fetch("{% url 'atlas:collection_case_display_setup' case_detail.collection.pk case_detail.case.pk %}", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": "{{ csrf_token }}"
},
body: JSON.stringify({reset_viewerstate: true})
})
.then(response => response.text())
.then(html => {
document.getElementById('viewerstate-save-response').innerHTML = html;
// Optionally reload the page or viewer here if needed
})
.catch(error => {
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Error resetting viewer state.</span>";
});
});
</script>
{% endblock %}
@@ -18,6 +18,8 @@
<li data-question_pk={{casedetail.case.pk}}><a title="sort_order: {{casedetail.sort_order}}" href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a> <li data-question_pk={{casedetail.case.pk}}><a title="sort_order: {{casedetail.sort_order}}" href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
: {{casedetail.case.title}} : {{casedetail.case.title}}
(<a href="{% url 'atlas:collection_case_display_setup' casedetail.collection.pk casedetail.case.pk %}">setup default display</a>)
{% if collection.collection_type == "QUE" %} {% if collection.collection_type == "QUE" %}
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>edit questions</a> (<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>edit questions</a>
&nbsp; &nbsp;
+97 -4
View File
@@ -9,23 +9,29 @@
<p>Click on the 'Load' button to open the case in a the linked window. Loading subsequent cases/series will replace the first case in the window.</p> <p>Click on the 'Load' button to open the case in a the linked window. Loading subsequent cases/series will replace the first case in the window.</p>
<p>It is also possible to open a single series by clicking on the 'Load' button next to the individual series.</p> <p>It is also possible to open a single series by clicking on the 'Load' button next to the individual series.</p>
<p>Once opened the case details will be show on the right hand side of the page.</p> <p>Once opened the case details will be show on the right hand side of the page.</p>
<div class="mb-2">
<button id="toggle-load-mode" class="btn btn-secondary">Switch to Load OHIF</button>
</div>
</details> </details>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<h3>Cases</h3> <h3>Cases</h3>
<button id="open-viewer" >Open Viewer</button> <button id="open-viewer" >Open Viewer OHIF</button>
<button id="open-viewer-local" >Open Viewer Local</button>
<button id="view-series" class="text-dark border-dark" >Toggle series</button> <button id="view-series" class="text-dark border-dark" >Toggle series</button>
<ol id="" class="sortable"> <ol id="" class="sortable">
{% for case in cases %} {% for case in cases %}
<li data-question_pk={{case.pk}}> <li data-question_pk={{case.pk}}>
{{case.casedetail.sort_order}} {{case.casedetail.sort_order}}
<div class="case-item" data-title="{{case.title}}" data-case={{case.pk}} data-case-json='{{case.get_viva_details_json}}'>Case {{forloop.counter}}: {{case.title}} &nbsp; <div class="case-item" data-title="{{case.title}}" data-case={{case.pk}} data-casejson='{{case.get_viva_details_json}}' data-images='{{case.get_series_images_nested}}'>Case {{forloop.counter}}: {{case.title}} &nbsp;
{% comment %} <button class="show-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Show</button> {% endcomment %} {% comment %} <button class="show-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Show</button> {% endcomment %}
{% comment %} <button class="send-message no-padding" data-title="{{case.title}}" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open</button> {% endcomment %} {% comment %} <button class="send-message no-padding" data-title="{{case.title}}" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}">Open</button> {% endcomment %}
{% comment %} <button class="open-case no-padding" data-target="{% url 'atlas:collection_viva_case' collection.pk forloop.counter0 %}" data-type="case">Load</button> {% endcomment %} {% comment %} <button class="open-case no-padding" data-target="{% url 'atlas:collection_viva_case' collection.pk forloop.counter0 %}" data-type="case">Load</button> {% endcomment %}
<button class="open-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}" data-type="case">Load</button> <button class="open-case no-padding" data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:case_dicom_json' case.pk %}" data-type="case">Load OHIF</button>
<button class="open-case-local no-padding" data-target=case.pk data-type="case">Load</button>
<br /> <br />
<div class="pre-whitespace multi-image-block"> <div class="pre-whitespace multi-image-block">
<details class="series-detail"><summary>Series:</summary> <details class="series-detail"><summary>Series:</summary>
@@ -42,6 +48,13 @@
<button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}"> <button class="open-series no-padding text-secondary border-secondary" data-type="series" data-series={{forloop.counter}} data-case={{case.pk}} data-target="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:series_dicom_json' series.pk %}">
Load Load
</button> </button>
<button class="open-series-local no-padding text-secondary border-secondary"
data-type="series"
data-series="{{ forloop.counter }}"
data-case="{{ case.pk }}"
data-target="{{ series.pk }}">
Load
</button>
<br> <br>
</span> </span>
</span> </span>
@@ -79,6 +92,38 @@
var win2 = false; var win2 = false;
$(document).ready(function() { $(document).ready(function() {
let loadLocalMode = true;
// Update all "Load" buttons to reflect the current mode
function updateLoadButtons() {
if (loadLocalMode) {
$('.open-case').hide();
$('.open-series').hide();
$('.open-case-local').show();
$('.open-series-local').show();
$('#toggle-load-mode').text('Switch to Load OHIF');
$("#open-viewer").hide()
$("#open-viewer-local").show()
} else {
$('.open-case').show();
$('.open-series').show();
$('.open-case-local').hide();
$('.open-series-local').hide();
$("#open-viewer").show()
$("#open-viewer-local").hide()
$('#toggle-load-mode').text('Switch to Load Local');
}
}
// Initial state: show only normal Load buttons
updateLoadButtons();
// Toggle button click handler
$('#toggle-load-mode').click(function() {
loadLocalMode = !loadLocalMode;
updateLoadButtons();
});
const bc = new BroadcastChannel("viva_load"); const bc = new BroadcastChannel("viva_load");
bc.onmessage = (event) => { bc.onmessage = (event) => {
console.log("message received"); console.log("message received");
@@ -88,6 +133,12 @@
$("#open-viewer").removeClass("flash-button"); $("#open-viewer").removeClass("flash-button");
//bc.postMessage({"type": "open-complete", "url" : event.data.url}); //bc.postMessage({"type": "open-complete", "url" : event.data.url});
} }
if (event.data.type == "open-complete-local") {
$("#loading-case").hide();
$("#open-viewer-local").removeClass("flash-button");
//bc.postMessage({"type": "open-complete-local"});
}
}; };
console.log('ready') console.log('ready')
$('.show-collection, .show-case, .show-series').click(function() { $('.show-collection, .show-case, .show-series').click(function() {
@@ -96,6 +147,43 @@
$('#case-title').text($(this).data('title')); $('#case-title').text($(this).data('title'));
$('#viewer').attr('src', url) $('#viewer').attr('src', url)
}); });
$('.open-case-local').click(function() {
let c = this.closest(".case-item");
console.log('clicked local', c)
console.log('open local', c.dataset.case)
case_details = JSON.parse(c.dataset.casejson);
$('#loading-case').show()
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> "+c.dataset.title);
$("#current-case-history").html("<span class='title'>History:</span> "+case_details['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> "+case_details['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> "+case_details['report']);
bc.postMessage({"type": "open", "case" : c.dataset.case, "images": c.dataset.images});
});
$('.open-series-local').click(function() {
let c = $(this).closest(".case-item")[0];
let seriesNumber = $(this).data('series');
let seriesPk = $(this).data('target');
let caseDetails = JSON.parse(c.dataset.casejson);
let images = JSON.parse(c.dataset.images)[seriesNumber - 1] || [];
$('#loading-case').show();
$("#open-viewer-local").addClass("flash-button");
$('#current-case-title').html("<span class='title'>Case:</span> " + c.dataset.title);
$('#current-case-series').html("<span class='title'>Series:</span> " + seriesNumber);
$("#current-case-history").html("<span class='title'>History:</span> " + caseDetails['history']);
$("#current-case-discussion").html("<span class='title'>Discussion:</span> " + caseDetails['discussion']);
$("#current-case-report").html("<span class='title'>Report:</span> " + caseDetails['report']);
// Send only the images for this series
bc.postMessage({
"type": "open",
"case": c.dataset.case,
"series": seriesPk,
"images": JSON.stringify(images)
});
});
$('.open-case, .open-series').click(function() { $('.open-case, .open-series').click(function() {
var url = $(this).data('target'); var url = $(this).data('target');
console.log('clicked', url) console.log('clicked', url)
@@ -103,7 +191,7 @@
let c = $(this).closest(".case-item"); let c = $(this).closest(".case-item");
case_details = c.data('case-json'); case_details = c.data('casejson');
console.log(case_details); console.log(case_details);
$('#loading-case').show() $('#loading-case').show()
@@ -137,6 +225,11 @@
openSecondaryWindow("{% url 'atlas:collection_viewer' %}"); openSecondaryWindow("{% url 'atlas:collection_viewer' %}");
//bc.postMessage(url); //bc.postMessage(url);
}) })
$('#open-viewer-local').click(function() {
$("#open-viewer-local").removeClass("flash-button");
openSecondaryWindow("{% url 'atlas:collection_viewer_local' %}");
//bc.postMessage(url);
})
$('#view-series').click(function() { $('#view-series').click(function() {
$(".series-detail").attr("open", (_, attr) => !attr); $(".series-detail").attr("open", (_, attr) => !attr);
//document.getElementsByClassName("series-detail").toggleAttribute("open"); //document.getElementsByClassName("series-detail").toggleAttribute("open");
+87 -2
View File
@@ -1,8 +1,6 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block css %}
{% endblock %}
{% block content %} {% block content %}
@@ -20,8 +18,95 @@
</div> </div>
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>. View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
</details> </details>
<details class="mt-3">
<summary>
<strong>Extra options</strong>
</summary>
<form id="bulk-delete-form" method="post" action="{% url 'atlas:series_bulk_delete' %}">
{% csrf_token %}
<button type="submit" class="btn btn-danger mb-2" onclick="return confirm('Delete selected series?');">
Delete Selected
</button>
</form>
</details>
{% render_table table %} {% render_table table %}
</div> </div>
<script>
document.getElementById('bulk-delete-form').addEventListener('submit', function(e) {
// Remove any previously added hidden inputs
document.querySelectorAll('#bulk-delete-form input[name="series_ids"]').forEach(el => el.remove());
// Find all checked checkboxes
const checked = document.querySelectorAll('input[name="selection"]:checked');
if (checked.length === 0) {
alert("Please select at least one series to delete.");
e.preventDefault();
return false;
}
// Add hidden inputs for each checked series
checked.forEach(cb => {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'series_ids';
hidden.value = cb.value;
this.appendChild(hidden);
});
});
</script>
<div id="exam-options"></div> <div id="exam-options"></div>
{% endblock %} {% endblock %}
{% block js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Delegate click event to all table rows
document.querySelectorAll('.table tbody tr').forEach(function(row) {
row.addEventListener('click', function(e) {
// Ignore clicks on links or checkboxes
if (e.target.tagName === 'A' || e.target.tagName === 'INPUT') return;
// Find the checkbox in this row
const checkbox = row.querySelector('input[name="selection"]');
if (checkbox) {
checkbox.checked = !checkbox.checked;
row.classList.toggle('selected', checkbox.checked);
}
});
// Keep row visually in sync with checkbox state (e.g. after page reload)
const checkbox = row.querySelector('input[name="selection"]');
if (checkbox && checkbox.checked) {
row.classList.add('selected');
}
// Also toggle row selection when checkbox is clicked directly
if (checkbox) {
checkbox.addEventListener('click', function(e) {
row.classList.toggle('selected', checkbox.checked);
// Prevent row click event from firing
e.stopPropagation();
});
}
});
});
</script>
{% endblock %}
{% block css %}
<style>
/* Highlight row on hover */
.table tbody tr:hover {
background-color: darkblue;
cursor: pointer;
}
/* Highlight selected row */
.table tbody tr.selected {
background-color: #b3d7ff !important;
color: #333;
}
</style>
{% endblock css %}
+1 -1
View File
@@ -44,7 +44,7 @@ Associated case:
</div> {% endcomment %} </div> {% endcomment %}
<div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}" <div id="root" class="dicom-viewer-root" data-images="{{ image_url_array_and_count.0 }}"
style="width: 1000px; height: 600px; box-sizing: border-box; background: #222;" style="max-width: 1000px; height: 600px; box-sizing: border-box; background: #222;"
data-auto-cache-stack=true data-auto-cache-stack=true
></div> ></div>
+78 -15
View File
@@ -68,23 +68,16 @@
hx-confirm="This will clear selected uploads, Continue?" hx-confirm="This will clear selected uploads, Continue?"
title="Deleted selected uploads" title="Deleted selected uploads"
>Delete Uploads</button> >Delete Uploads</button>
{% if case %}
<button id="import-dicoms-case-button"
hx-post="{% url 'api-1:import_dicoms_case' case.id %}"
hx-target=".imported"
{% comment %} hx-indicator=".indicator" {% endcomment %}
hx-include=".selected,[name='order-series']"
>Import into case</button>
{% else %} <button id="import-dicoms-sequential-button"
<button id="import-dicoms-button" type="button"
hx-post="{% url 'api-1:import_dicoms' %}" class="btn btn-primary"
hx-target=".imported" >Import {% if case %}into case{% endif %} (one at a time)</button>
{% comment %} hx-indicator=".indicator" {% endcomment %}
hx-include=".selected,[name='order-series']" <div id="import-status"></div>
>Import</button>
{% endif %}
<div class="indicator"><div class="loader"></div>Loading...</div> <div class="indicator"><div class="loader"></div>Loading...</div>
<div id="import-status"></div>
<div class="imported"> <div class="imported">
@@ -96,6 +89,72 @@
<p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p> <p><a href="{% url 'atlas:series_view' %}?case=null&sort=-modified_date">View orphan series</a></p>
<script>
document.getElementById('import-dicoms-sequential-button').addEventListener('click', async function() {
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
// Only include checkboxes that are not disabled (i.e., not already imported)
const selectableCheckboxes = allCheckboxes.filter(cb => !cb.disabled);
const toImport = checkboxes.length
? checkboxes.filter(cb => !cb.disabled)
: selectableCheckboxes;
if (toImport.length === 0) {
alert("Please select at least one series to import (not already imported).");
return;
}
const statusDiv = document.getElementById('import-status');
statusDiv.innerHTML = '';
const orderSeries = document.querySelector('input[name="order-series"]:checked').value;
const csrfToken = '{{ csrf_token }}';
{% if case %}
const importUrl = "{% url 'api-1:import_dicoms_case' case.id %}";
{% else %}
const importUrl = "{% url 'api-1:import_dicoms' %}";
{% endif %}
for (let i = 0; i < toImport.length; i++) {
const seriesId = toImport[i].value;
statusDiv.innerHTML += `<div id="import-status-${seriesId}">Importing series ${seriesId}...</div>`;
try {
const response = await fetch(importUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": csrfToken,
},
body: `selection=${encodeURIComponent(seriesId)}&order-series=${encodeURIComponent(orderSeries)}`
});
const text = await response.text();
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: ${text}`;
// Mark as imported in the UI and make unselectable
const li = document.querySelector(`#series-list input[value="${seriesId}"]`)?.closest('li');
if (li && !li.classList.contains('imported')) {
li.classList.add('imported');
// Add badge if not already present
if (!li.querySelector('.badge.bg-success')) {
const badge = document.createElement('span');
badge.className = 'badge bg-success ms-2';
badge.textContent = 'Imported';
li.appendChild(badge);
}
// Disable checkbox and prevent further selection
const checkbox = li.querySelector('input[name="selection"]');
if (checkbox) {
checkbox.disabled = true;
checkbox.checked = false;
}
// Remove click handler for selection
li.querySelector('span').onclick = null;
li.style.pointerEvents = "none";
li.style.opacity = "0.7";
}
} catch (e) {
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: <span style="color:red;">Failed</span>`;
}
}
statusDiv.innerHTML += "<div>All done.</div>";
});
</script>
{% endblock %} {% endblock %}
@@ -155,6 +214,10 @@
.series-block-popup-link { .series-block-popup-link {
float: right; float: right;
} }
#series-list li.imported {
border-color: #e6ffe6;
opacity: 0.7;
}
</style> </style>
{% endblock css %} {% endblock css %}
+54
View File
@@ -0,0 +1,54 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>Viewer</title>
<style type="text/css">
body, html
{
margin: 0; padding: 0; height: 100%; overflow: hidden;
}
#viewer
{
position:absolute; left: 0; right: 0; bottom: 0; top: 0px;
}
</style>
<script src="{% static 'dv3d/index.js' %}" type="module" defer="defer" type="module"></script>
</head>
<body>
<div id="awating-load">
<p>Awating load request</p>
This page will display series when loaded from other pages.
</div>
<div id="root" class="dicom-viewer-root"
style="box-sizing: border-box; background: #222; position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100vw; height: 100vh;"
data-auto-cache-stack="true"
></div>
<script type="text/javascript">
var win2 = false;
document.addEventListener("DOMContentLoaded", function() {
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
bc.onmessage = (event) => {
console.log("message received");
console.log(event);
if (event.data.type == "open") {
document.getElementById("awating-load").style.display = "none";
console.log("open, images: ", event.data.images);
document.getElementById("root").dataset.images = event.data.images;
window.mountDicomViewers();
bc.postMessage({"type": "open-complete-local"});
}
};
});
</script>
</body>
</html>
+7
View File
@@ -70,6 +70,7 @@ urlpatterns = [
name="exam_update", name="exam_update",
), ),
path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"), path("collection/viewer", TemplateView.as_view(template_name="atlas/viewer.html"), name="collection_viewer"),
path("collection/viewer_local", TemplateView.as_view(template_name="atlas/viewer_local.html"), name="collection_viewer_local"),
path("collection/<int:pk>", views.collection_detail, name="collection_detail"), path("collection/<int:pk>", views.collection_detail, name="collection_detail"),
path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"), path("collection/<int:pk>/viva", views.collection_viva, name="collection_viva"),
path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"), path("collection/<int:pk>/viva/<int:case_number>", views.collection_viva_case, name="collection_viva_case"),
@@ -218,6 +219,11 @@ urlpatterns = [
views.collection_case_view, views.collection_case_view,
name="collection_case_view", name="collection_case_view",
), ),
path(
"collection/<int:collection_id>/<int:case_id>/display_setup",
views.collection_case_display_setup,
name="collection_case_display_setup",
),
path( path(
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>", "collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
views.collection_case_view_take, views.collection_case_view_take,
@@ -266,6 +272,7 @@ urlpatterns = [
), ),
path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'), path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'),
path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'), path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'),
path('series/bulk-delete/', views.series_bulk_delete, name='series_bulk_delete'),
path('series_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'), path('series_finding/<int:finding_id>/details/', views.series_finding_details, name='series_finding_details'),
path('series_finding/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'), path('series_finding/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'),
path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"), path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
+40
View File
@@ -3181,4 +3181,44 @@ def seriesfinding_migration_status(request):
}) })
return render(request, "atlas/seriesfinding_migration_status.html", { return render(request, "atlas/seriesfinding_migration_status.html", {
"findings_status": findings_status "findings_status": findings_status
})
def series_bulk_delete(request):
if request.method == "POST":
ids = request.POST.getlist("series_ids")
# Only delete series the user has permission to delete
series_to_delete = Series.objects.filter(pk__in=ids)
deleted_ids = []
for series in series_to_delete:
if series.check_user_can_edit(request.user):
series.delete()
deleted_ids.append(series.pk)
# Optionally, you could add a message here about which were deleted
return redirect('atlas:series_view')
@user_is_collection_author_or_atlas_editor
@csrf_exempt # Only if you have CSRF issues; otherwise, keep CSRF protection
def collection_case_display_setup(request, collection_id, case_id):
"""
View to set up the display options for a specific case in a collection.
This can include setting which series to display, annotations, etc.
"""
case_detail = get_object_or_404(CaseDetail, case__pk=case_id, collection__pk=collection_id)
if request.method == "POST":
data = json.loads(request.body.decode("utf-8"))
if data.get("reset_viewerstate"):
case_detail.default_viewerstate = None
case_detail.save()
return HttpResponse("<span class='text-success'>Viewer state reset to default.</span>")
viewerstate = data.get("viewerstate")
if viewerstate is not None:
case_detail.default_viewerstate = viewerstate
case_detail.save()
return HttpResponse("<span class='text-success'>Viewer state saved.</span>")
else:
return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400)
return render(request, "atlas/collection_case_display_setup.html", {
"case_detail": case_detail
}) })
+132 -132
View File
File diff suppressed because one or more lines are too long