From 70ea3788caf09789ec943836006e05ffaa5435c9 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 27 Apr 2026 22:16:06 +0100 Subject: [PATCH] feat: Add functionality to move selected series between cases with a new form and UI enhancements --- atlas/forms.py | 21 ++ atlas/templates/atlas/case_display_block.html | 207 +++++++++++++----- atlas/urls.py | 5 + atlas/views.py | 79 ++++++- 4 files changed, 256 insertions(+), 56 deletions(-) diff --git a/atlas/forms.py b/atlas/forms.py index 16d6f5eb..32b03426 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -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", diff --git a/atlas/templates/atlas/case_display_block.html b/atlas/templates/atlas/case_display_block.html index 3e568f61..4d6de113 100755 --- a/atlas/templates/atlas/case_display_block.html +++ b/atlas/templates/atlas/case_display_block.html @@ -417,50 +417,80 @@ Manage Series - Create and add new series
- - - - - +
+ Create and add new series + +
+ + +
+ +
+ + + + + +
+ +
+
Move selected series to another case
+ {% if move_series_form %} +
+ {{ move_series_form.destination_case }} + +
+ {% else %} +
Case move options are unavailable in this view.
+ {% endif %} +
+
@@ -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
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); } } diff --git a/atlas/urls.py b/atlas/urls.py index 2ddf12e2..2fdf7ad0 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -460,6 +460,11 @@ urlpatterns = [ views.remove_selected_series_from_case, name="remove_selected_series_from_case", ), + path( + "case//series_move/", + views.move_selected_series_to_case, + name="move_selected_series_to_case", + ), path('case//reorder_series/', views.reorder_series, name='reorder_series'), path( "series//dicom_json", diff --git a/atlas/views.py b/atlas/views.py index d3c60d48..2a0619b9 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -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)