i should do these more often
This commit is contained in:
+28
-30
@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
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 django.core.exceptions import PermissionDenied
|
||||
@@ -109,7 +109,6 @@ def clear_dicoms(request):
|
||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||
|
||||
dicoms.delete()
|
||||
print(dicoms)
|
||||
|
||||
return True
|
||||
|
||||
@@ -163,28 +162,31 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
|
||||
tags = data[series_uid][0][1]
|
||||
|
||||
# Check if series with the id already exists (in which case we just add to htat)
|
||||
if Series.objects.filter(
|
||||
series_instance_uid=tags["SeriesInstanceUID"]
|
||||
).exists():
|
||||
series = Series.objects.get(series_instance_uid=tags["SeriesInstanceUID"])
|
||||
else:
|
||||
series = Series(
|
||||
modality=modality,
|
||||
description=tags["SeriesDescription"],
|
||||
series_instance_uid=tags["SeriesInstanceUID"],
|
||||
)
|
||||
|
||||
if tags["StudyDescription"]:
|
||||
examination, created = Examination.objects.get_or_create(
|
||||
examination=tags["StudyDescription"]
|
||||
try:
|
||||
with transaction.atomic():
|
||||
series, created = Series.objects.get_or_create(
|
||||
series_instance_uid=tags["SeriesInstanceUID"],
|
||||
defaults={
|
||||
"modality": modality,
|
||||
"description": tags["SeriesDescription"],
|
||||
}
|
||||
)
|
||||
if created:
|
||||
examination.modality = modality
|
||||
examination.save()
|
||||
series.examination = examination
|
||||
if created and tags["StudyDescription"]:
|
||||
examination, created_exam = Examination.objects.get_or_create(
|
||||
examination=tags["StudyDescription"]
|
||||
)
|
||||
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....
|
||||
@@ -192,8 +194,11 @@ def import_dicoms_helper(request, case_id: int | None = None):
|
||||
|
||||
if case_id is not None:
|
||||
case_object = get_object_or_404(Case, pk=case_id)
|
||||
if not series.case.contains(case_object):
|
||||
try:
|
||||
series.case.add(case_object)
|
||||
except IntegrityError:
|
||||
# The relation already exists, safe to ignore
|
||||
pass
|
||||
|
||||
for dicom, dicom_tags in data[series_uid]:
|
||||
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])
|
||||
def orphan_series(request):
|
||||
# print(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)
|
||||
def series_truncate(request, series_id: int, start: int, end: int):
|
||||
print(start, end)
|
||||
series = get_object_or_404(Series, pk=series_id)
|
||||
|
||||
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:
|
||||
continue
|
||||
else:
|
||||
print(n, image)
|
||||
image.removed = True
|
||||
image.image = None
|
||||
image.save()
|
||||
@@ -291,7 +293,6 @@ def get_cases_user(request):
|
||||
@router.get("/check_image_hash/{hash}", auth=django_auth)
|
||||
def check_image_hash(request, hash: str):
|
||||
try:
|
||||
print(hash)
|
||||
series_image = SeriesImage.objects.get(image_blake3_hash=hash)
|
||||
data = {
|
||||
"status": "success",
|
||||
@@ -372,11 +373,8 @@ def series_split_by_tag(request, series_id: int, dicom_tag: str):
|
||||
|
||||
# Split teh associated images
|
||||
for image in series.images.all():
|
||||
print(image)
|
||||
ds = image.get_dicom_data()
|
||||
|
||||
# print("2a", ds[dicom_tag])
|
||||
|
||||
if dicom_tag in ds:
|
||||
val = ds[dicom_tag].value
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@ def user_is_collection_author_or_atlas_editor(function):
|
||||
def wrap(request, *args, **kwargs):
|
||||
if "exam_id" in kwargs:
|
||||
atlas = CaseCollection.objects.get(pk=kwargs["exam_id"])
|
||||
elif "collection_id" in kwargs:
|
||||
atlas = CaseCollection.objects.get(pk=kwargs["collection_id"])
|
||||
else:
|
||||
atlas = CaseCollection.objects.get(pk=kwargs["pk"])
|
||||
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
@@ -541,10 +541,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
return True
|
||||
|
||||
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):
|
||||
@@ -1107,6 +1118,12 @@ class CaseDetail(models.Model):
|
||||
# TODO add feedback for questions
|
||||
#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)
|
||||
|
||||
class Meta:
|
||||
@@ -1129,6 +1146,9 @@ class CaseDetail(models.Model):
|
||||
except UserReportAnswer.DoesNotExist:
|
||||
return None
|
||||
|
||||
def default_viewerstate_string(self):
|
||||
return json.dumps(self.default_viewerstate) if self.default_viewerstate else ""
|
||||
|
||||
class CasePrior(models.Model):
|
||||
case_detail = models.ForeignKey(CaseDetail, on_delete=models.CASCADE)
|
||||
prior_case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="prior_case")
|
||||
|
||||
@@ -440,8 +440,7 @@
|
||||
}, 200);
|
||||
|
||||
document.getElementById("reload-finding").addEventListener("click", function () {
|
||||
importAnnotations_root(annotationjson3d)
|
||||
importViewerState_root(viewerState);
|
||||
window.mountDicomViewers();
|
||||
});
|
||||
|
||||
// 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>
|
||||
: {{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" %}
|
||||
(<a href='{% url "atlas:collection_case_details" casedetail.collection.pk casedetail.case.pk %}'>edit questions</a>
|
||||
|
||||
|
||||
@@ -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>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>
|
||||
|
||||
<div class="mb-2">
|
||||
<button id="toggle-load-mode" class="btn btn-secondary">Switch to Load OHIF</button>
|
||||
</div>
|
||||
</details>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<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>
|
||||
<ol id="" class="sortable">
|
||||
{% for case in cases %}
|
||||
|
||||
<li data-question_pk={{case.pk}}>
|
||||
{{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}}
|
||||
<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}}
|
||||
{% 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="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 />
|
||||
<div class="pre-whitespace multi-image-block">
|
||||
<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 %}">
|
||||
Load
|
||||
</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>
|
||||
</span>
|
||||
</span>
|
||||
@@ -79,6 +92,38 @@
|
||||
var win2 = false;
|
||||
|
||||
$(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");
|
||||
bc.onmessage = (event) => {
|
||||
console.log("message received");
|
||||
@@ -88,6 +133,12 @@
|
||||
$("#open-viewer").removeClass("flash-button");
|
||||
//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')
|
||||
$('.show-collection, .show-case, .show-series').click(function() {
|
||||
@@ -96,6 +147,43 @@
|
||||
$('#case-title').text($(this).data('title'));
|
||||
$('#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() {
|
||||
var url = $(this).data('target');
|
||||
console.log('clicked', url)
|
||||
@@ -103,7 +191,7 @@
|
||||
|
||||
let c = $(this).closest(".case-item");
|
||||
|
||||
case_details = c.data('case-json');
|
||||
case_details = c.data('casejson');
|
||||
console.log(case_details);
|
||||
|
||||
$('#loading-case').show()
|
||||
@@ -137,6 +225,11 @@
|
||||
openSecondaryWindow("{% url 'atlas:collection_viewer' %}");
|
||||
//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() {
|
||||
$(".series-detail").attr("open", (_, attr) => !attr);
|
||||
//document.getElementsByClassName("series-detail").toggleAttribute("open");
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -20,8 +18,95 @@
|
||||
</div>
|
||||
View my <a href='{% url "atlas:case_view" %}?author={{request.user.id}}'>cases</a>.
|
||||
</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 %}
|
||||
</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>
|
||||
{% 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 %}
|
||||
|
||||
@@ -44,7 +44,7 @@ Associated case:
|
||||
</div> {% endcomment %}
|
||||
|
||||
<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
|
||||
></div>
|
||||
|
||||
|
||||
@@ -68,23 +68,16 @@
|
||||
hx-confirm="This will clear selected uploads, Continue?"
|
||||
title="Deleted selected uploads"
|
||||
>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-button"
|
||||
hx-post="{% url 'api-1:import_dicoms' %}"
|
||||
hx-target=".imported"
|
||||
{% comment %} hx-indicator=".indicator" {% endcomment %}
|
||||
hx-include=".selected,[name='order-series']"
|
||||
>Import</button>
|
||||
{% endif %}
|
||||
<button id="import-dicoms-sequential-button"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
>Import {% if case %}into case{% endif %} (one at a time)</button>
|
||||
|
||||
<div id="import-status"></div>
|
||||
|
||||
<div class="indicator"><div class="loader"></div>Loading...</div>
|
||||
<div id="import-status"></div>
|
||||
|
||||
<div class="imported">
|
||||
|
||||
@@ -96,6 +89,72 @@
|
||||
|
||||
<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 %}
|
||||
|
||||
|
||||
@@ -155,6 +214,10 @@
|
||||
.series-block-popup-link {
|
||||
float: right;
|
||||
}
|
||||
#series-list li.imported {
|
||||
border-color: #e6ffe6;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock css %}
|
||||
|
||||
@@ -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>
|
||||
@@ -70,6 +70,7 @@ urlpatterns = [
|
||||
name="exam_update",
|
||||
),
|
||||
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>/viva", views.collection_viva, name="collection_viva"),
|
||||
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,
|
||||
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(
|
||||
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
|
||||
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>/<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/migration-status/', views.seriesfinding_migration_status, name='seriesfinding_migration_status'),
|
||||
path("uncategorised_dicoms/", views.uncategorised_dicoms, name="uncategorised_dicoms_view"),
|
||||
|
||||
@@ -3181,4 +3181,44 @@ def seriesfinding_migration_status(request):
|
||||
})
|
||||
return render(request, "atlas/seriesfinding_migration_status.html", {
|
||||
"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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user