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) 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): def get_app_name(self):
return "atlas" return "atlas"
+172 -15
View File
@@ -1,7 +1,7 @@
{% load partials %} {% load partials %}
{% partialdef case-series %} {% 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 class="series-block" id="series-block-{{ series.pk }}">
<span> <span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br> <span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
@@ -9,7 +9,12 @@
{{series.get_block}} {{series.get_block}}
</a> </a>
<br> <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"> <span class="series-block-popup-link">
<a href="#" <a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a> onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
@@ -144,13 +149,13 @@
{% partial case-series %} {% partial case-series %}
</form> </form>
<span> <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 /> <a href="{% url 'atlas:user_uploads_case' case_id=case.pk %}">Import new uploads</a><br />
<details class="series-actions" id="series-actions"> <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 %}" <button hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location" title="order dicom by slice location"
hx-target="this" hx-target="#series-action-results"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-confirm="This will reorder all case series based upon slice location" hx-confirm="This will reorder all case series based upon slice location"
> >
@@ -159,7 +164,7 @@
<button hx-post="{% url 'atlas:combine_series' %}" <button hx-post="{% url 'atlas:combine_series' %}"
title="merge series" title="merge series"
hx-include="[name='series-ids']" hx-include="[name='series-ids']"
hx-target="#series-merge-results" hx-target="#series-action-results"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="This will merge all selected series into one" 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 %}" <button hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
title="merge series" title="merge series"
hx-include="[name='series-ids']" hx-include="[name='series-ids']"
hx-target="#series-merge-results" hx-target="#series-action-results"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="This will use the dicom date as the series description" hx-confirm="This will use the dicom date as the series description"
> >
Use dates as description Use dates as description
</button> </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/> <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> </details>
</span> </span>
@@ -204,7 +228,7 @@
{% endif %} {% endif %}
{% if case.series.all %} {% if case.series.all %}
{% for series in case.series.all %} {% for detail in case.get_ordered_series %}
{% for finding in series.findings.all %} {% for finding in series.findings.all %}
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}" <div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}"
_="on mouseenter _="on mouseenter
@@ -346,6 +370,40 @@
</div> </div>
</div> </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> <script>
// From series_viewer.html // From series_viewer.html
@@ -381,19 +439,112 @@
} }
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const detailsElement = document.getElementById("series-actions"); // Move up/down logic
const checkboxes = document.querySelectorAll("input[name='series-ids']"); 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() { // On submit, update the hidden inputs to match the new order
const isOpen = detailsElement.hasAttribute("open"); document.getElementById("reorder-series-form").addEventListener("submit", function(e) {
checkboxes.forEach(checkbox => { const lis = document.querySelectorAll("#series-reorder-list li");
checkbox.style.display = isOpen ? "inline-block" : "none"; 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 // Initial state
toggleCheckboxes(); 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 // Listen for toggle events on the <details> element
detailsElement.addEventListener("toggle", toggleCheckboxes); detailsElement.addEventListener("toggle", toggleCheckboxes);
@@ -466,4 +617,10 @@
background-color: blue; background-color: blue;
transition: background-color 0.3s ease, border 0.3s ease; 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> </style>
+6
View File
@@ -265,6 +265,12 @@ urlpatterns = [
views.remove_case_from_collection, views.remove_case_from_collection,
name="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( path(
"series/<int:pk>/dicom_json", "series/<int:pk>/dicom_json",
views.series_dicom_json, views.series_dicom_json,
+31
View File
@@ -89,6 +89,7 @@ from .models import (
Series, Series,
Examination, Examination,
Finding, Finding,
SeriesDetail,
Structure, Structure,
Subspecialty, Subspecialty,
SeriesFinding, SeriesFinding,
@@ -3204,6 +3205,36 @@ def remove_case_from_collection(request, case_pk, collection_pk):
return HttpResponse(f"Case removed from collection: {collection.name}") 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): def linked_cases_overview(request, case_id):
""" """
View to display an overview of linked cases via the previous_case field. View to display an overview of linked cases via the previous_case field.