feat: Add functionality to move selected series between cases with a new form and UI enhancements
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user