feat: Add functionality to move selected series between cases with a new form and UI enhancements

This commit is contained in:
Ross
2026-04-27 22:16:06 +01:00
parent 3772846e55
commit 70ea3788ca
4 changed files with 256 additions and 56 deletions
+21
View File
@@ -117,6 +117,26 @@ class CaseSelect(Select):
pass
class MoveSelectedSeriesForm(Form):
destination_case = ModelChoiceField(
queryset=Case.objects.none(),
widget=CaseSelect(attrs={"class": "form-select form-select-sm"}),
label="Move selected series to case",
required=True,
)
def __init__(self, *args, **kwargs):
user = kwargs.pop("user", None)
source_case = kwargs.pop("source_case", None)
super().__init__(*args, **kwargs)
available_cases = get_cases_available_to_user(user)
if source_case is not None:
available_cases = available_cases.exclude(pk=source_case.pk)
self.fields["destination_case"].queryset = available_cases.order_by("pk")
class ConditionForm(ModelForm):
class Meta:
model = Condition
@@ -268,6 +288,7 @@ class CaseCollectionForm(ModelForm):
"exam_mode",
"self_review",
"feedback_once_collection_complete",
"case_query_messaging_enabled",
"viewer_mode",
"question_time_limit",
"prerequisites",
+153 -54
View File
@@ -417,50 +417,80 @@
Manage Series
</button>
</summary>
<a href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a><br />
<button class="btn btn-sm btn-outline-secondary mb-1" hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will reorder all case series based upon slice location"
>
Order dicoms by slice location
</button>
<button class="btn btn-sm btn-outline-primary mb-1" hx-post="{% url 'atlas:combine_series' %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will merge all selected series into one"
>
Merge selected series
</button>
<button class="btn btn-sm btn-outline-secondary mb-1" hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
title="merge series"
hx-include="[name='series-ids']"
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>
<div class="series-actions-body">
<a class="btn btn-sm btn-outline-secondary text-start" href="{% url 'atlas:series_id_create' pk=case.pk %}">Create and add new series</a>
<div class="series-selection-toolbar d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="series-select-all-btn">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="series-deselect-all-btn">Deselect all</button>
</div>
<div class="series-actions-grid d-flex flex-column gap-2">
<button class="btn btn-sm btn-outline-secondary text-start" hx-get="{% url 'atlas:case_order_dicom' pk=case.pk %}"
title="order dicom by slice location"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will reorder all case series based upon slice location"
>
Order dicoms by slice location
</button>
<button class="btn btn-sm btn-outline-primary text-start" hx-post="{% url 'atlas:combine_series' %}"
title="merge series"
hx-include="[name='series-ids']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="This will merge all selected series into one"
>
Merge selected series
</button>
<button class="btn btn-sm btn-outline-secondary text-start" hx-post="{% url 'atlas:use_dates_as_descriptions' case.pk %}"
title="use dicom dates as series descriptions"
hx-include="[name='series-ids']"
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-sm btn-warning text-start"
type="button"
>
Remove selected series from case
</button>
<button type="button"
class="btn btn-sm btn-secondary text-start"
data-bs-toggle="modal"
data-bs-target="#reorderSeriesModal">
Reorder series
</button>
</div>
<div class="series-move-panel border rounded p-2">
<div class="small fw-semibold mb-2">Move selected series to another case</div>
{% if move_series_form %}
<form id="move-selected-series-form" class="d-flex flex-column gap-2">
{{ move_series_form.destination_case }}
<button type="button"
class="btn btn-sm btn-outline-primary text-start"
hx-post="{% url 'atlas:move_selected_series_to_case' case.pk %}"
hx-include="[name='series-ids']:checked, #move-selected-series-form [name='destination_case']"
hx-target="#series-action-results"
hx-swap="innerHTML"
hx-confirm="Move selected series to the chosen destination case?">
Move selected series
</button>
</form>
{% else %}
<div class="text-muted small">Case move options are unavailable in this view.</div>
{% endif %}
</div>
</div>
<div class="alert alert-info mt-2" id="series-action-alert" style="display:none;">
<span id="series-action-results" ></span>
</div>
@@ -901,32 +931,80 @@
const detailsElement = document.getElementById("series-actions");
const buttons = document.querySelectorAll(".select-series-btn");
const seriesCheckboxes = document.querySelectorAll("input[type='checkbox'][name='series-ids']");
const selectAllBtn = document.getElementById("series-select-all-btn");
const deselectAllBtn = document.getElementById("series-deselect-all-btn");
function toggleCheckboxes() {
const isOpen = detailsElement.hasAttribute("open");
buttons.forEach(button => {
const isOpen = detailsElement && 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() {
function syncSeriesSelectionUi() {
document.querySelectorAll(".select-series-btn").forEach(function(btn) {
const parent = btn.closest(".series-block");
if (!parent) {
return;
}
const checkbox = parent.querySelector("input[type='checkbox'][name='series-ids']");
checkbox.checked = !checkbox.checked;
if (!checkbox) {
return;
}
btn.classList.toggle("btn-primary", checkbox.checked);
btn.classList.toggle("btn-outline-primary", !checkbox.checked);
btn.textContent = checkbox.checked ? "Selected" : "Select";
// Highlight the series-block when selected
if (checkbox.checked) {
parent.classList.add("highlight-series");
} else {
parent.classList.remove("highlight-series");
}
});
const selectedCount = Array.from(seriesCheckboxes).filter((checkbox) => checkbox.checked).length;
if (selectAllBtn) {
selectAllBtn.disabled = selectedCount === seriesCheckboxes.length && seriesCheckboxes.length > 0;
}
if (deselectAllBtn) {
deselectAllBtn.disabled = selectedCount === 0;
}
}
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;
syncSeriesSelectionUi();
});
});
seriesCheckboxes.forEach((checkbox) => {
checkbox.addEventListener("change", syncSeriesSelectionUi);
});
if (selectAllBtn) {
selectAllBtn.addEventListener("click", function() {
seriesCheckboxes.forEach((checkbox) => {
checkbox.checked = true;
});
syncSeriesSelectionUi();
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener("click", function() {
seriesCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
syncSeriesSelectionUi();
});
}
// Initial state
toggleCheckboxes();
syncSeriesSelectionUi();
// Show the alert when a result is received
@@ -942,7 +1020,9 @@
observer.observe(resultsSpan, { childList: true, subtree: true });
// Listen for toggle events on the <details> element
detailsElement.addEventListener("toggle", toggleCheckboxes);
if (detailsElement) {
detailsElement.addEventListener("toggle", toggleCheckboxes);
}
const modal = new bootstrap.Modal(document.getElementById("findingModal"));
const modalBody = document.getElementById("findingModalBody");
@@ -1231,12 +1311,31 @@
background: rgba(255,255,255,0.02);
margin-top: .5rem;
}
.series-actions-body {
display: flex;
flex-direction: column;
gap: .5rem;
}
.series-selection-toolbar {
border-top: 1px solid rgba(0,0,0,0.08);
border-bottom: 1px solid rgba(0,0,0,0.08);
padding: .5rem 0;
}
.series-actions-grid .btn,
.series-actions-body > a.btn,
.series-move-panel .btn {
width: 100%;
}
.series-move-panel {
background: rgba(255,255,255,0.03);
}
.series-move-panel .case-details {
margin-bottom: .5rem;
}
.series-actions summary { list-style: none; }
.series-actions summary::-webkit-details-marker { display:none; }
.series-actions .btn { vertical-align: middle; }
.series-actions > a, .series-actions > button { width: 100%; text-align: left; }
.series-actions > button.btn.btn-warning,
.series-actions > button.btn.btn-secondary { width: auto; align-self: flex-start; }
.series-actions .btn { text-align: left; }
@media (prefers-color-scheme: dark) {
.series-actions { background: rgba(255,255,255,0.02); border-color: rgba(255,255,255,0.04); }
}
+5
View File
@@ -460,6 +460,11 @@ urlpatterns = [
views.remove_selected_series_from_case,
name="remove_selected_series_from_case",
),
path(
"case/<int:case_pk>/series_move/",
views.move_selected_series_to_case,
name="move_selected_series_to_case",
),
path('case/<int:case_pk>/reorder_series/', views.reorder_series, name='reorder_series'),
path(
"series/<int:pk>/dicom_json",
+77 -2
View File
@@ -12,7 +12,7 @@ from django.utils import timezone
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_http_methods
from django.template.loader import render_to_string
from django.db.models import Count
from django.db.models import Count, Max
from django.template.defaultfilters import filesizeformat
# from django.contrib.auth.models import User
@@ -66,6 +66,7 @@ from .forms import (
CaseCollectionUpdateCaseForm,
CaseDetailForm,
CaseQuestionForm,
MoveSelectedSeriesForm,
CaseDisplaySetForm,
CaseForm,
CaseResourceFormSet,
@@ -829,6 +830,7 @@ def get_case_for_case_detail(pk: int) -> Case:
def case_detail(request, pk):
case = get_case_for_case_detail(pk)
can_edit = case.check_user_can_edit(request.user)
move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case)
return render(
@@ -838,6 +840,7 @@ def case_detail(request, pk):
"case": case,
"cimar_sid": request.user.userprofile.cimar_sid,
"can_edit": can_edit,
"move_series_form": move_series_form,
# Provide helper lists for resource search filters
"site_queryset": Site.objects.all(),
"subspecialty_queryset": Subspecialty.objects.all(),
@@ -4156,7 +4159,10 @@ def collection_history(request, exam_id: int):
def _get_case_review_stats_for_exam(
*, cid_user_exam: CidUserExam, casedetails: list[CaseDetail] | QuerySet[CaseDetail]
*,
cid_user_exam: CidUserExam,
casedetails: list[CaseDetail] | QuerySet[CaseDetail],
messaging_enabled: bool = True,
) -> tuple[dict[int, dict[str, int | bool]], int, int]:
case_ids = [casedetail.case_id for casedetail in casedetails]
stats_by_case = {
@@ -4173,6 +4179,9 @@ def _get_case_review_stats_for_exam(
for case_id in case_ids
}
if not messaging_enabled:
return stats_by_case, 0, 0
message_rows = (
CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case_id__in=case_ids)
.values("case_id")
@@ -4237,6 +4246,7 @@ def _build_collection_feedback_rows(
stats_by_case, outstanding_case_count, total_outstanding_feedback = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
rows = []
@@ -4695,6 +4705,15 @@ def _user_can_moderate_collection(collection: CaseCollection, user: User) -> boo
)
def _collection_case_query_messaging_enabled(collection: CaseCollection) -> bool:
return bool(getattr(collection, "case_query_messaging_enabled", True))
def _enforce_collection_case_query_messaging_enabled(collection: CaseCollection):
if not _collection_case_query_messaging_enabled(collection):
raise Http404("Case query and messaging are disabled for this collection")
def _get_case_review_thread_context(
*,
collection: CaseCollection,
@@ -7153,6 +7172,62 @@ def remove_selected_series_from_case(request, case_pk):
SeriesDetail.objects.filter(case=case, series_id__in=ids).delete()
return HttpResponse(f"Series removed from case.")
@login_required
@require_POST
def move_selected_series_to_case(request, case_pk):
source_case = get_object_or_404(Case, pk=case_pk)
if not source_case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit this case.", status=403)
form = MoveSelectedSeriesForm(request.POST, user=request.user, source_case=source_case)
if not form.is_valid():
return HttpResponse("Please choose a destination case.", status=400)
target_case = form.cleaned_data["destination_case"]
if not target_case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit the destination case.", status=403)
ids = request.POST.getlist("series-ids")
if not ids:
return HttpResponse("No series selected to move.")
series_to_move = list(
Series.objects.filter(pk__in=ids, case=source_case).distinct()
)
if not series_to_move:
return HttpResponse("No valid series selected to move.", status=400)
for series in series_to_move:
if not series.check_user_can_edit(request.user):
return HttpResponse(
"You do not have permission to edit one or more selected series.",
status=403,
)
next_sort_order = (
SeriesDetail.objects.filter(case=target_case).aggregate(max_sort=Max("sort_order"))["max_sort"] or -1
) + 1
added_to_target = 0
for series in series_to_move:
link_exists = SeriesDetail.objects.filter(case=target_case, series=series).exists()
if not link_exists:
SeriesDetail.objects.create(
case=target_case,
series=series,
sort_order=next_sort_order,
)
next_sort_order += 1
added_to_target += 1
SeriesDetail.objects.filter(case=source_case, series=series).delete()
return HttpResponse(
f"Moved {len(series_to_move)} series to case {target_case.pk}. Added {added_to_target} new links in destination case."
)
@require_POST
def reorder_series(request, case_pk):
case = get_object_or_404(Case, pk=case_pk)