.
This commit is contained in:
@@ -396,6 +396,22 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def get_ordered_series_details(self):
|
||||
# If the series are already prefetched, use them directly
|
||||
if hasattr(self, '_prefetched_objects_cache') and 'series' in self._prefetched_objects_cache:
|
||||
# Get the through model and sort by sort_order
|
||||
through_model = self.series.through
|
||||
# Build a mapping from series pk to through instance
|
||||
through_objs = list(through_model.objects.filter(case=self).select_related('series'))
|
||||
through_objs.sort(key=lambda x: x.sort_order)
|
||||
return through_objs
|
||||
# Otherwise, query as before
|
||||
return self.series.through.objects.filter(case=self).select_related('series').order_by('sort_order')
|
||||
|
||||
def get_ordered_series(self):
|
||||
"""Returns the series in the case in order of the SeriesDetail sort_order"""
|
||||
return [sd.series for sd in self.get_ordered_series_details()]
|
||||
|
||||
def get_app_name(self):
|
||||
return "atlas"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% load partials %}
|
||||
|
||||
{% partialdef case-series %}
|
||||
{% for series in case.series.all %}
|
||||
{% for series in case.get_ordered_series %}
|
||||
<span class="series-block" id="series-block-{{ series.pk }}">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
|
||||
@@ -9,7 +9,12 @@
|
||||
{{series.get_block}}
|
||||
</a>
|
||||
<br>
|
||||
<input type="checkbox" name="series-ids" value="{{series.pk}}">
|
||||
<button type="button"
|
||||
class="btn btn-outline-primary btn-sm select-series-btn"
|
||||
data-series-id="{{series.pk}}">
|
||||
Select
|
||||
</button>
|
||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||
<span class="series-block-popup-link">
|
||||
<a href="#"
|
||||
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
|
||||
@@ -144,13 +149,13 @@
|
||||
{% partial case-series %}
|
||||
</form>
|
||||
<span>
|
||||
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Add new series</a><br />
|
||||
<a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
|
||||
<details class="series-actions" id="series-actions">
|
||||
<summary>+</summary>
|
||||
<summary>+ Manage Series</summary>
|
||||
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a><br />
|
||||
<button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
|
||||
title="order dicom by slice location"
|
||||
hx-target="this"
|
||||
hx-target="#series-action-results"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="This will reorder all case series based upon slice location"
|
||||
>
|
||||
@@ -159,7 +164,7 @@
|
||||
<button hx-post="{% url 'atlas:combine_series' %}"
|
||||
title="merge series"
|
||||
hx-include="[name='series-ids']"
|
||||
hx-target="#series-merge-results"
|
||||
hx-target="#series-action-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="This will merge all selected series into one"
|
||||
>
|
||||
@@ -168,14 +173,33 @@
|
||||
<button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
|
||||
title="merge series"
|
||||
hx-include="[name='series-ids']"
|
||||
hx-target="#series-merge-results"
|
||||
hx-target="#series-action-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="This will use the dicom date as the series description"
|
||||
>
|
||||
Use dates as description
|
||||
</button>
|
||||
<button hx-post="{% url 'atlas:remove_selected_series_from_case' case.pk %}"
|
||||
title="remove selected series from this case"
|
||||
hx-include="[name='series-ids']:checked"
|
||||
hx-target="#series-action-results"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to remove the selected series from this case? They will not be deleted from the database."
|
||||
class="btn btn-warning mt-2"
|
||||
type="button"
|
||||
>
|
||||
Remove selected series from case
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-secondary mt-2"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#reorderSeriesModal">
|
||||
Reorder series
|
||||
</button>
|
||||
<br/>
|
||||
<span id="series-merge-results"></span>
|
||||
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
|
||||
<span id="series-action-results" ></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</span>
|
||||
@@ -204,7 +228,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if case.series.all %}
|
||||
{% for series in case.series.all %}
|
||||
{% for detail in case.get_ordered_series %}
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}"
|
||||
_="on mouseenter
|
||||
@@ -346,6 +370,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reorder Series Modal -->
|
||||
<div class="modal fade" id="reorderSeriesModal" tabindex="-1" aria-labelledby="reorderSeriesModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{% url 'atlas:reorder_series' case.pk %}" id="reorder-series-form">
|
||||
{% csrf_token %}
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="reorderSeriesModalLabel">Reorder Series</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul id="series-reorder-list" class="list-group">
|
||||
{% for series in case.get_ordered_series %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center" data-series-id="{{ series.pk }}">
|
||||
<span>
|
||||
<span class="series-block-series-number">Series {{ forloop.counter }}:</span>
|
||||
{{ series.get_block|default:series }}
|
||||
</span>
|
||||
<span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary move-up">↑</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary move-down">↓</button>
|
||||
</span>
|
||||
<input type="hidden" name="series_order" value="{{ series.pk }}">
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary">Save order</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// From series_viewer.html
|
||||
@@ -381,19 +439,112 @@
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const detailsElement = document.getElementById("series-actions");
|
||||
const checkboxes = document.querySelectorAll("input[name='series-ids']");
|
||||
// Move up/down logic
|
||||
document.querySelectorAll("#series-reorder-list .move-up, #series-reorder-list .move-down").forEach(function(btn) {
|
||||
btn.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
const li = btn.closest("li");
|
||||
if (btn.classList.contains("move-up") && li.previousElementSibling) {
|
||||
li.parentNode.insertBefore(li, li.previousElementSibling);
|
||||
}
|
||||
if (btn.classList.contains("move-down") && li.nextElementSibling) {
|
||||
li.parentNode.insertBefore(li.nextElementSibling, li);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function toggleCheckboxes() {
|
||||
const isOpen = detailsElement.hasAttribute("open");
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.style.display = isOpen ? "inline-block" : "none";
|
||||
// On submit, update the hidden inputs to match the new order
|
||||
document.getElementById("reorder-series-form").addEventListener("submit", function(e) {
|
||||
const lis = document.querySelectorAll("#series-reorder-list li");
|
||||
lis.forEach((li, idx) => {
|
||||
li.querySelector("input[name='series_order']").setAttribute("name", "series_order_" + idx);
|
||||
});
|
||||
});
|
||||
|
||||
// Drag and drop reordering for series list
|
||||
const reorderList = document.getElementById("series-reorder-list");
|
||||
let draggedItem = null;
|
||||
|
||||
if (reorderList) {
|
||||
reorderList.querySelectorAll("li").forEach(function(li) {
|
||||
li.setAttribute("draggable", "true");
|
||||
|
||||
li.addEventListener("dragstart", function(e) {
|
||||
draggedItem = li;
|
||||
li.classList.add("dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
|
||||
li.addEventListener("dragend", function() {
|
||||
draggedItem = null;
|
||||
li.classList.remove("dragging");
|
||||
});
|
||||
|
||||
li.addEventListener("dragover", function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
// Visual feedback
|
||||
li.classList.add("drag-over");
|
||||
});
|
||||
|
||||
li.addEventListener("dragleave", function() {
|
||||
li.classList.remove("drag-over");
|
||||
});
|
||||
|
||||
li.addEventListener("drop", function(e) {
|
||||
e.preventDefault();
|
||||
li.classList.remove("drag-over");
|
||||
if (draggedItem && draggedItem !== li) {
|
||||
// Insert before or after depending on mouse position
|
||||
const bounding = li.getBoundingClientRect();
|
||||
const offset = e.clientY - bounding.top;
|
||||
if (offset < bounding.height / 2) {
|
||||
li.parentNode.insertBefore(draggedItem, li);
|
||||
} else {
|
||||
li.parentNode.insertBefore(draggedItem, li.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const detailsElement = document.getElementById("series-actions");
|
||||
const buttons = document.querySelectorAll(".select-series-btn");
|
||||
|
||||
function toggleCheckboxes() {
|
||||
const isOpen = detailsElement.hasAttribute("open");
|
||||
buttons.forEach(button => {
|
||||
button.style.display = isOpen ? "inline-block" : "none";
|
||||
});
|
||||
}
|
||||
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
const parent = btn.closest(".series-block");
|
||||
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
|
||||
checkbox.checked = !checkbox.checked;
|
||||
btn.classList.toggle("btn-primary", checkbox.checked);
|
||||
btn.classList.toggle("btn-outline-primary", !checkbox.checked);
|
||||
btn.textContent = checkbox.checked ? "Selected" : "Select";
|
||||
});
|
||||
});
|
||||
|
||||
// Initial state
|
||||
toggleCheckboxes();
|
||||
|
||||
|
||||
// Show the alert when a result is received
|
||||
const resultsSpan = document.getElementById("series-action-results");
|
||||
const alertDiv = document.getElementById("series-action-alert");
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
if (resultsSpan.textContent.trim() !== "") {
|
||||
alertDiv.style.display = "block";
|
||||
} else {
|
||||
alertDiv.style.display = "none";
|
||||
}
|
||||
});
|
||||
observer.observe(resultsSpan, { childList: true, subtree: true });
|
||||
|
||||
// Listen for toggle events on the <details> element
|
||||
detailsElement.addEventListener("toggle", toggleCheckboxes);
|
||||
|
||||
@@ -466,4 +617,10 @@
|
||||
background-color: blue;
|
||||
transition: background-color 0.3s ease, border 0.3s ease;
|
||||
}
|
||||
#series-reorder-list .dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
#series-reorder-list .drag-over {
|
||||
border-top: 2px solid #0d6efd;
|
||||
}
|
||||
</style>
|
||||
@@ -265,6 +265,12 @@ urlpatterns = [
|
||||
views.remove_case_from_collection,
|
||||
name="remove_case_from_collection",
|
||||
),
|
||||
path(
|
||||
"case/<int:case_pk>/series_remove/",
|
||||
views.remove_selected_series_from_case,
|
||||
name="remove_selected_series_from_case",
|
||||
),
|
||||
path('case/<int:case_pk>/reorder_series/', views.reorder_series, name='reorder_series'),
|
||||
path(
|
||||
"series/<int:pk>/dicom_json",
|
||||
views.series_dicom_json,
|
||||
|
||||
@@ -89,6 +89,7 @@ from .models import (
|
||||
Series,
|
||||
Examination,
|
||||
Finding,
|
||||
SeriesDetail,
|
||||
Structure,
|
||||
Subspecialty,
|
||||
SeriesFinding,
|
||||
@@ -3204,6 +3205,36 @@ def remove_case_from_collection(request, case_pk, collection_pk):
|
||||
return HttpResponse(f"Case removed from collection: {collection.name}")
|
||||
|
||||
|
||||
@require_POST
|
||||
def remove_selected_series_from_case(request, case_pk):
|
||||
ids = request.POST.getlist('series-ids')
|
||||
case = Case.objects.get(pk=case_pk)
|
||||
|
||||
if not case.check_user_can_edit(request.user):
|
||||
return HttpResponse("You do not have permission to edit this case.", status=403)
|
||||
|
||||
|
||||
if not ids:
|
||||
return HttpResponse("No series selected for removal.")
|
||||
|
||||
# Remove the association (for a through model)
|
||||
SeriesDetail.objects.filter(case=case, series_id__in=ids).delete()
|
||||
return HttpResponse(f"Series removed from case.")
|
||||
|
||||
@require_POST
|
||||
def reorder_series(request, case_pk):
|
||||
case = get_object_or_404(Case, pk=case_pk)
|
||||
if not case.check_user_can_edit(request.user):
|
||||
return HttpResponse("You do not have permission to edit this case.", status=403)
|
||||
# Get new order from POST
|
||||
order = []
|
||||
for key in sorted([k for k in request.POST if k.startswith("series_order_")]):
|
||||
order.append(int(request.POST[key]))
|
||||
# Update sort_order in SeriesDetail
|
||||
for idx, series_id in enumerate(order):
|
||||
SeriesDetail.objects.filter(case=case, series_id=series_id).update(sort_order=idx)
|
||||
return redirect(request.META.get("HTTP_REFERER", "/"))
|
||||
|
||||
def linked_cases_overview(request, case_id):
|
||||
"""
|
||||
View to display an overview of linked cases via the previous_case field.
|
||||
|
||||
Reference in New Issue
Block a user