This commit is contained in:
Ross
2025-07-07 12:58:17 +01:00
parent 0cdccbcdfb
commit 804cff33f4
4 changed files with 225 additions and 15 deletions
+16
View File
@@ -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"
+172 -15
View File
@@ -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>
+6
View File
@@ -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,
+31
View File
@@ -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.