From 55fcf71431bbf594756f979f8d3fb7fb5678f166 Mon Sep 17 00:00:00 2001 From: Ross Date: Sat, 4 Jul 2026 23:11:19 +0100 Subject: [PATCH] Add refresh buttons and optimize case series update - Implemented refresh buttons for each exam/packet section and a global refresh button. - Enhanced resource management in the atlas by allowing inline creation and attachment of resources. - Redirected users back to the case within a case collection upon saving or cancelling edits. - Optimized the case series update view to prevent hanging issues on production. - Fixed an issue with the local-up script not shutting down running containers properly. Add marker feedback field to useranswer model - Created migrations to add a 'marker_feedback' field to the 'useranswer' model in both 'longs' and 'shorts' apps. - The new field is a TextField that allows optional feedback for the marker, which will not be shown to the candidate. --- atlas/forms.py | 2 + atlas/views.py | 2478 ++++++++++++----- .../0039_useranswer_marker_feedback.py | 18 + .../0015_useranswer_marker_feedback.py | 18 + todo | 22 +- 5 files changed, 1789 insertions(+), 749 deletions(-) create mode 100644 longs/migrations/0039_useranswer_marker_feedback.py create mode 100644 shorts/migrations/0015_useranswer_marker_feedback.py diff --git a/atlas/forms.py b/atlas/forms.py index 67b93386..a77e009f 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -1495,9 +1495,11 @@ class CaseSeriesForm(forms.ModelForm): def __init__(self, *args, queryset, user=None, is_atlas_editor=False, **kwargs): super().__init__(*args, **kwargs) self.fields["series"].queryset = queryset + self.fields["series"].label_from_instance = lambda obj: str(obj.pk) class Meta: model = SeriesDetail # through model for Case.series + fields = ["series", "sort_order", "feedback"] diff --git a/atlas/views.py b/atlas/views.py index 654305fe..e4517c82 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -92,7 +92,7 @@ from .forms import ( ExamGroupsForm, FindingForm, JsonAnswerForm, - #ModelPriorCaseForm, + # ModelPriorCaseForm, PriorCaseForm, QuestionSchemaForm, ResourceForm, @@ -141,8 +141,8 @@ from .models import ( SeriesImage, UncategorisedDicom, UserReportAnswer, - PrerequisiteRequired - , APIToken, + PrerequisiteRequired, + APIToken, _build_declared_series_groups, ) from .models import Procedure @@ -229,7 +229,13 @@ def _format_relative_date_offset(days_delta): "value": value, } -from .tasks import push_case_to_cimar_task, series_downsample_task, series_reconstruct_task + +from .tasks import ( + push_case_to_cimar_task, + series_downsample_task, + series_reconstruct_task, +) + try: from django_tasks import TaskResultStatus except ImportError: @@ -241,6 +247,7 @@ except ImportError: FAILED = "FAILED" SUCCESSFUL = "SUCCESSFUL" + try: from django_tasks_db.models import DBTaskResult except ImportError: @@ -299,13 +306,13 @@ from loguru import logger def series_truncate_htmx(request, series_id, start, end): """HTMX endpoint to truncate a series' images to the given inclusive start/end bounds (zero-based). Returns an HTML fragment with the result. - + This endpoint only performs truncation for backward compatibility. """ series = get_object_or_404(Series, pk=series_id) if not series.check_user_can_edit(request.user): - return HttpResponse("
Permission denied
") + return HttpResponse('
Permission denied
') images_removed = [] @@ -368,8 +375,12 @@ def _resize_array_nearest(arr, target_rows, target_cols): import numpy as np rows, cols = arr.shape[0], arr.shape[1] - row_idx = np.clip(np.round(np.linspace(0, rows - 1, target_rows)).astype(int), 0, rows - 1) - col_idx = np.clip(np.round(np.linspace(0, cols - 1, target_cols)).astype(int), 0, cols - 1) + row_idx = np.clip( + np.round(np.linspace(0, rows - 1, target_rows)).astype(int), 0, rows - 1 + ) + col_idx = np.clip( + np.round(np.linspace(0, cols - 1, target_cols)).astype(int), 0, cols - 1 + ) return arr[np.ix_(row_idx, col_idx)] @@ -441,13 +452,17 @@ def _downsample_dicom_dataset( px = ds.pixel_array if px.ndim != 2: - raise ValueError("Only 2D DICOM slices are supported for this downsample operation") + raise ValueError( + "Only 2D DICOM slices are supported for this downsample operation" + ) target_rows = max(1, int(round(px.shape[0] * factor))) target_cols = max(1, int(round(px.shape[1] * factor))) resized = _resize_array_nearest(px, target_rows, target_cols) - derivation_description = f"Downsampled by {reduction_pct}% using nearest-neighbour resize" + derivation_description = ( + f"Downsampled by {reduction_pct}% using nearest-neighbour resize" + ) _stamp_derived_dataset( ds, series_instance_uid=series_instance_uid, @@ -462,15 +477,29 @@ def _downsample_dicom_dataset( col_spacing = _safe_float(ds.PixelSpacing[1], 1.0) ds.PixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)] - if hasattr(ds, "ImagerPixelSpacing") and ds.ImagerPixelSpacing and len(ds.ImagerPixelSpacing) >= 2: + if ( + hasattr(ds, "ImagerPixelSpacing") + and ds.ImagerPixelSpacing + and len(ds.ImagerPixelSpacing) >= 2 + ): row_spacing = _safe_float(ds.ImagerPixelSpacing[0], 1.0) col_spacing = _safe_float(ds.ImagerPixelSpacing[1], 1.0) - ds.ImagerPixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)] + ds.ImagerPixelSpacing = [ + float(row_spacing / factor), + float(col_spacing / factor), + ] - if hasattr(ds, "NominalScannedPixelSpacing") and ds.NominalScannedPixelSpacing and len(ds.NominalScannedPixelSpacing) >= 2: + if ( + hasattr(ds, "NominalScannedPixelSpacing") + and ds.NominalScannedPixelSpacing + and len(ds.NominalScannedPixelSpacing) >= 2 + ): row_spacing = _safe_float(ds.NominalScannedPixelSpacing[0], 1.0) col_spacing = _safe_float(ds.NominalScannedPixelSpacing[1], 1.0) - ds.NominalScannedPixelSpacing = [float(row_spacing / factor), float(col_spacing / factor)] + ds.NominalScannedPixelSpacing = [ + float(row_spacing / factor), + float(col_spacing / factor), + ] ds.Rows = int(target_rows) ds.Columns = int(target_cols) @@ -487,7 +516,9 @@ def _create_series_derivative(source_series, description_suffix): derived.id = None derived.series_instance_uid = generate_uid() derived.source_series_instance_uid = source_series.series_instance_uid - derived.description = f"{source_series.description or 'Series'} [{description_suffix}]" + derived.description = ( + f"{source_series.description or 'Series'} [{description_suffix}]" + ) derived.modified = Series.SeriesModifiedChocies.RE derived.save() derived.case.set(source_series.case.all()) @@ -519,7 +550,11 @@ def _infer_native_spacings(dicom_items): row_spacing = 1.0 col_spacing = 1.0 - if hasattr(first_ds, "PixelSpacing") and first_ds.PixelSpacing and len(first_ds.PixelSpacing) >= 2: + if ( + hasattr(first_ds, "PixelSpacing") + and first_ds.PixelSpacing + and len(first_ds.PixelSpacing) >= 2 + ): row_spacing = _safe_float(first_ds.PixelSpacing[0], 1.0) col_spacing = _safe_float(first_ds.PixelSpacing[1], 1.0) @@ -534,7 +569,11 @@ def _infer_native_spacings(dicom_items): if p0 and p1 and len(p0) == 3 and len(p1) == 3: import numpy as np - z_spacing = float(np.linalg.norm(np.array(p1, dtype=float) - np.array(p0, dtype=float))) + z_spacing = float( + np.linalg.norm( + np.array(p1, dtype=float) - np.array(p0, dtype=float) + ) + ) except Exception: z_spacing = None @@ -557,8 +596,12 @@ def _extract_recon_geometry(dicom_items): iop = getattr(first_ds, "ImageOrientationPatient", None) if iop and len(iop) >= 6: try: - row_dir = np.array([float(iop[0]), float(iop[1]), float(iop[2])], dtype=float) - col_dir = np.array([float(iop[3]), float(iop[4]), float(iop[5])], dtype=float) + row_dir = np.array( + [float(iop[0]), float(iop[1]), float(iop[2])], dtype=float + ) + col_dir = np.array( + [float(iop[3]), float(iop[4]), float(iop[5])], dtype=float + ) except Exception: row_dir = np.array([1.0, 0.0, 0.0], dtype=float) col_dir = np.array([0.0, 1.0, 0.0], dtype=float) @@ -582,7 +625,10 @@ def _extract_recon_geometry(dicom_items): try: ipp_attr = getattr(ds, "ImagePositionPatient", None) if ipp_attr and len(ipp_attr) >= 3: - ipp = np.array([float(ipp_attr[0]), float(ipp_attr[1]), float(ipp_attr[2])], dtype=float) + ipp = np.array( + [float(ipp_attr[0]), float(ipp_attr[1]), float(ipp_attr[2])], + dtype=float, + ) except Exception: ipp = None @@ -606,8 +652,14 @@ def _extract_recon_geometry(dicom_items): if native_z_spacing <= 0: native_z_spacing = 1.0 - origin_ipp = sorted_ipps[0] if sorted_ipps else np.array([0.0, 0.0, 0.0], dtype=float) - source_positions_mm = sorted_proj - sorted_proj[0] if len(sorted_proj) > 0 else np.array([0.0], dtype=float) + origin_ipp = ( + sorted_ipps[0] if sorted_ipps else np.array([0.0, 0.0, 0.0], dtype=float) + ) + source_positions_mm = ( + sorted_proj - sorted_proj[0] + if len(sorted_proj) > 0 + else np.array([0.0], dtype=float) + ) return { "sorted_items": sorted_items, @@ -622,7 +674,9 @@ def _extract_recon_geometry(dicom_items): } -def _aggregate_volume_along_z(volume, source_positions_mm, target_spacing, slab_thickness, mode): +def _aggregate_volume_along_z( + volume, source_positions_mm, target_spacing, slab_thickness, mode +): """Aggregate a z-volume into slabbed slices using spacing/thickness in mm.""" import numpy as np @@ -637,7 +691,9 @@ def _aggregate_volume_along_z(volume, source_positions_mm, target_spacing, slab_ start_pos = float(source_positions_mm[0]) end_pos = float(source_positions_mm[-1]) - centers = np.arange(start_pos, end_pos + (target_spacing * 0.5), target_spacing, dtype=float) + centers = np.arange( + start_pos, end_pos + (target_spacing * 0.5), target_spacing, dtype=float + ) if centers.size == 0: centers = np.array([start_pos], dtype=float) @@ -663,7 +719,9 @@ def _aggregate_volume_along_z(volume, source_positions_mm, target_spacing, slab_ if np.issubdtype(volume.dtype, np.integer): info = np.iinfo(volume.dtype) - out_volume = np.clip(np.rint(out_volume), info.min, info.max).astype(volume.dtype) + out_volume = np.clip(np.rint(out_volume), info.min, info.max).astype( + volume.dtype + ) else: out_volume = out_volume.astype(volume.dtype, copy=False) @@ -687,17 +745,29 @@ def series_optimize_htmx(request, series_id): series = get_object_or_404(Series, pk=series_id) if not series.check_user_can_edit(request.user): - return HttpResponse('
Permission denied
') + return HttpResponse( + '
Permission denied
' + ) operation = request.POST.get("operation", "").strip() start = int(request.POST.get("start", "1") or 1) - 1 - end = int(request.POST.get("end", str(series.get_image_count())) or series.get_image_count()) - 1 + end = ( + int( + request.POST.get("end", str(series.get_image_count())) + or series.get_image_count() + ) + - 1 + ) if end < start: - return HttpResponse('
Start must be less than or equal to end.
') + return HttpResponse( + '
Start must be less than or equal to end.
' + ) images = list(series.get_images()) - bounded_images = [img for idx, img in enumerate(images) if idx >= start and idx <= end] + bounded_images = [ + img for idx, img in enumerate(images) if idx >= start and idx <= end + ] full_series_images = images if operation == "truncate": @@ -717,16 +787,14 @@ def series_optimize_htmx(request, series_id): pass new_total = int(series.get_image_count()) - payload = ( - f'
' - ) + payload = f'
' return HttpResponse( ( '
' - f'Truncate complete. Removed {len(removed)} images. ' - f'Updated working range to 1-{new_total}.' - '
' - f'{payload}' + f"Truncate complete. Removed {len(removed)} images. " + f"Updated working range to 1-{new_total}." + "" + f"{payload}" ) ) @@ -758,7 +826,9 @@ def series_optimize_htmx(request, series_id): downsample_pct = int(request.POST.get("downsample_pct", "50") or 50) if operation == "downsample_preview": - preview_series = _create_series_derivative(series, f"Downsample preview {downsample_pct}%") + preview_series = _create_series_derivative( + series, f"Downsample preview {downsample_pct}%" + ) created = 0 for image in full_series_images: @@ -775,14 +845,20 @@ def series_optimize_htmx(request, series_id): series_description=preview_series.description, ) except Exception as exc: - logger.warning("Downsample preview failed for image {}: {}", image.pk, exc) + logger.warning( + "Downsample preview failed for image {}: {}", image.pk, exc + ) continue out_io = io.BytesIO() ds_out.save_as(out_io, write_like_original=False) out_io.seek(0) - new_image = SeriesImage(series=preview_series, position=image.position, upload_filename=image.upload_filename) + new_image = SeriesImage( + series=preview_series, + position=image.position, + upload_filename=image.upload_filename, + ) new_image.image.save( f"preview_ds_{downsample_pct}_{image.pk}.dcm", ContentFile(out_io.getvalue()), @@ -804,9 +880,9 @@ def series_optimize_htmx(request, series_id): return HttpResponse( ( '
' - f'Downsample preview series created with {created} images. ' + f"Downsample preview series created with {created} images. " f'Open preview series.' - '
' + "" f'
' ) ) @@ -820,8 +896,8 @@ def series_optimize_htmx(request, series_id): return HttpResponse( ( '
' - 'Downsample queued. This runs in the background to avoid request timeout.' - '
' + "Downsample queued. This runs in the background to avoid request timeout." + "" f'
' '
' @@ -832,25 +908,37 @@ def series_optimize_htmx(request, series_id): if operation == "reconstruct": recon_planes = request.POST.getlist("recon_planes") if not recon_planes: - return HttpResponse('
Select at least one reconstruction plane.
') + return HttpResponse( + '
Select at least one reconstruction plane.
' + ) slice_thickness = request.POST.get("recon_slice_thickness", "") slice_spacing = request.POST.get("recon_slice_spacing", "") - recon_thickness_mode = (request.POST.get("recon_thickness_mode", "mean") or "mean").strip().lower() + recon_thickness_mode = ( + (request.POST.get("recon_thickness_mode", "mean") or "mean").strip().lower() + ) if recon_thickness_mode not in ("mean", "max", "min"): - return HttpResponse('
Invalid thickness merge mode.
') + return HttpResponse( + '
Invalid thickness merge mode.
' + ) try: slice_thickness_val = float(slice_thickness) if slice_thickness else None slice_spacing_val = float(slice_spacing) if slice_spacing else None except ValueError: - return HttpResponse('
Slice thickness and spacing must be numeric values.
') + return HttpResponse( + '
Slice thickness and spacing must be numeric values.
' + ) if slice_thickness_val is not None and slice_thickness_val <= 0: - return HttpResponse('
Slice thickness must be greater than 0.
') + return HttpResponse( + '
Slice thickness must be greater than 0.
' + ) if slice_spacing_val is not None and slice_spacing_val <= 0: - return HttpResponse('
Slice spacing must be greater than 0.
') + return HttpResponse( + '
Slice spacing must be greater than 0.
' + ) task_result = series_reconstruct_task.enqueue( series_id=series.pk, user_id=request.user.pk, @@ -863,8 +951,8 @@ def series_optimize_htmx(request, series_id): return HttpResponse( ( '
' - 'Reconstruction queued. This runs in the background to avoid request timeout.' - '
' + "Reconstruction queued. This runs in the background to avoid request timeout." + "
" f'
' '
' @@ -873,7 +961,9 @@ def series_optimize_htmx(request, series_id): ) ) - return HttpResponse('
Unknown optimize operation.
') + return HttpResponse( + '
Unknown optimize operation.
' + ) @login_required @@ -890,7 +980,11 @@ def series_reconstruct_advanced(request, series_id): if not series.check_user_can_edit(request.user): return HttpResponse("Permission denied", status=403) - image_urls = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images() if getattr(img, "image", None)] + image_urls = [ + f"{REMOTE_URL}{img.image.url}" + for img in series.get_images() + if getattr(img, "image", None) + ] image_ids = [f"wadouri:{url}" for url in image_urls] context = { @@ -904,11 +998,15 @@ def series_reconstruct_advanced(request, series_id): def series_reconstruct_status_htmx(request, series_id, task_id): """HTMX endpoint returning progress for a queued series reconstruction task.""" series = get_object_or_404(Series, pk=series_id) - target_id = (request.GET.get("target_id") or "recon-task-status").strip() or "recon-task-status" + target_id = ( + request.GET.get("target_id") or "recon-task-status" + ).strip() or "recon-task-status" target_id = escape(target_id) if not series.check_user_can_edit(request.user): - return HttpResponse(f'
Permission denied
') + return HttpResponse( + f'
Permission denied
' + ) try: task_result = series_reconstruct_task.get_result(task_id) @@ -917,12 +1015,17 @@ def series_reconstruct_status_htmx(request, series_id, task_id): f'
Task backend does not support result polling. Configure a backend with result retrieval support.
' ) except Exception: - return HttpResponse(f'
Task result not found.
') + return HttpResponse( + f'
Task result not found.
' + ) task_result.refresh() state = task_result.status - poll_url = reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_id}) + poll_url = reverse( + "atlas:series_reconstruct_status", + kwargs={"series_id": series.pk, "task_id": task_id}, + ) progress = cache.get(f"series_reconstruct_progress:{task_id}") or {} current = int(progress.get("current", 0) or 0) total = int(progress.get("total", 0) or 0) @@ -937,15 +1040,19 @@ def series_reconstruct_status_htmx(request, series_id, task_id): '
' f'
' - '
' + "
" f'
{current}/{total} slices processed
' - '
' + "
" ) ) if state == TaskResultStatus.SUCCESSFUL: try: - payload = task_result.return_value if isinstance(task_result.return_value, dict) else {} + payload = ( + task_result.return_value + if isinstance(task_result.return_value, dict) + else {} + ) except ValueError: payload = {} created_series = payload.get("created_series", []) @@ -960,12 +1067,14 @@ def series_reconstruct_status_htmx(request, series_id, task_id): return HttpResponse( ( f'
' - f'Reconstruction complete. Created {len(created_series)} series: {links}' - '
' + f"Reconstruction complete. Created {len(created_series)} series: {links}" + "" ) ) - return HttpResponse(f'
Reconstruction finished but no output series were created.
') + return HttpResponse( + f'
Reconstruction finished but no output series were created.
' + ) if state == TaskResultStatus.FAILED: err = "Unknown failure" @@ -984,11 +1093,15 @@ def series_reconstruct_status_htmx(request, series_id, task_id): def series_downsample_status_htmx(request, series_id, task_id): """HTMX endpoint returning progress for a queued series downsample task.""" series = get_object_or_404(Series, pk=series_id) - target_id = (request.GET.get("target_id") or "downsample-task-status").strip() or "downsample-task-status" + target_id = ( + request.GET.get("target_id") or "downsample-task-status" + ).strip() or "downsample-task-status" target_id = escape(target_id) if not series.check_user_can_edit(request.user): - return HttpResponse(f'
Permission denied
') + return HttpResponse( + f'
Permission denied
' + ) try: task_result = series_downsample_task.get_result(task_id) @@ -997,12 +1110,17 @@ def series_downsample_status_htmx(request, series_id, task_id): f'
Task backend does not support result polling. Configure a backend with result retrieval support.
' ) except Exception: - return HttpResponse(f'
Task result not found.
') + return HttpResponse( + f'
Task result not found.
' + ) task_result.refresh() state = task_result.status - poll_url = reverse("atlas:series_downsample_status", kwargs={"series_id": series.pk, "task_id": task_id}) + poll_url = reverse( + "atlas:series_downsample_status", + kwargs={"series_id": series.pk, "task_id": task_id}, + ) progress = cache.get(f"series_downsample_progress:{task_id}") or {} current = int(progress.get("current", 0) or 0) total = int(progress.get("total", 0) or 0) @@ -1017,9 +1135,9 @@ def series_downsample_status_htmx(request, series_id, task_id): '
' f'
' - '
' + "" f'
{current}/{total} images processed
' - '' + "" ) ) @@ -1061,11 +1179,15 @@ def task_overview(request): if request.method == "POST": action = (request.POST.get("task_action") or "").strip() - selected_ids = [task_id for task_id in request.POST.getlist("task_ids") if task_id] + selected_ids = [ + task_id for task_id in request.POST.getlist("task_ids") if task_id + ] if not selected_ids: messages.warning(request, "Select at least one task to run an action.") - return redirect(f"{reverse('atlas:task_overview')}?status={(request.GET.get('status') or 'all').strip().lower()}") + return redirect( + f"{reverse('atlas:task_overview')}?status={(request.GET.get('status') or 'all').strip().lower()}" + ) selected_qs = DBTaskResult.objects.filter(id__in=selected_ids) @@ -1103,7 +1225,9 @@ def task_overview(request): stale_minutes = 30 cutoff = timezone.now() - timedelta(minutes=stale_minutes) - updated = selected_qs.filter(status="RUNNING", started_at__lt=cutoff).update( + updated = selected_qs.filter( + status="RUNNING", started_at__lt=cutoff + ).update( status="READY", started_at=None, finished_at=None, @@ -1199,7 +1323,7 @@ def series_image_size_htmx(request, series_id): can_view = True if not can_view: - return HttpResponse("
Permission denied
") + return HttpResponse('
Permission denied
') refresh = request.GET.get("refresh") in ("1", "true", "True") @@ -1212,7 +1336,12 @@ def series_image_size_htmx(request, series_id): html = render_to_string( "atlas/partials/_series_image_size.html", - {"series": series, "size_bytes": size_bytes, "size_human": human, "refresh": refresh}, + { + "series": series, + "size_bytes": size_bytes, + "size_human": human, + "refresh": refresh, + }, request=request, ) @@ -1266,7 +1395,7 @@ def case_search_partial(request): # # If you want, I can prepare the migration, model/index changes and a # Django-code example to wire FTS into this view and tune weights. - q = request.GET.get('q', '') or request.GET.get('case-search-input', '') + q = request.GET.get("q", "") or request.GET.get("case-search-input", "") q = q.strip() # Start with cases available to the user @@ -1295,9 +1424,11 @@ def case_search_partial(request): | Q(author__username__icontains=q) ).distinct() - qs = qs.order_by('-created_date')[:20] + qs = qs.order_by("-created_date")[:20] - html = render_to_string('atlas/partials/_case_search_results.html', {'cases': qs}, request=request) + html = render_to_string( + "atlas/partials/_case_search_results.html", {"cases": qs}, request=request + ) return HttpResponse(html) @@ -1305,10 +1436,10 @@ def case_search_partial(request): def finding_search(request): """HTMX endpoint returning findings, optionally filtered by case and query.""" # Allow both HTMX and standard fetch/ajax requests (don't require the HTMX header) - q = request.GET.get('q', '').strip() - case_id = request.GET.get('case') + q = request.GET.get("q", "").strip() + case_id = request.GET.get("case") # Use SeriesFinding objects for the widget (series-scoped findings) - qs = SeriesFinding.objects.select_related('series').prefetch_related('findings') + qs = SeriesFinding.objects.select_related("series").prefetch_related("findings") if case_id: try: cid = int(case_id) @@ -1317,11 +1448,11 @@ def finding_search(request): except Exception: pass if q: - qs = qs.filter( - Q(description__icontains=q) | Q(findings__name__icontains=q) - ) - qs = qs.order_by('-pk').distinct()[:50] - html = render_to_string('atlas/partials/_finding_search_results.html', {'findings': qs}, request=request) + qs = qs.filter(Q(description__icontains=q) | Q(findings__name__icontains=q)) + qs = qs.order_by("-pk").distinct()[:50] + html = render_to_string( + "atlas/partials/_finding_search_results.html", {"findings": qs}, request=request + ) return HttpResponse(html) @@ -1329,8 +1460,8 @@ def finding_search(request): def displayset_search(request): """HTMX endpoint returning display sets, scoped to a case if provided.""" # Allow both HTMX and standard fetch/ajax requests (don't require the HTMX header) - q = request.GET.get('q', '').strip() - case_id = request.GET.get('case') + q = request.GET.get("q", "").strip() + case_id = request.GET.get("case") qs = CaseDisplaySet.objects.all() if case_id: try: @@ -1340,8 +1471,12 @@ def displayset_search(request): pass if q: qs = qs.filter(name__icontains=q) - qs = qs.order_by('name')[:50] - html = render_to_string('atlas/partials/_displayset_search_results.html', {'displaysets': qs}, request=request) + qs = qs.order_by("name")[:50] + html = render_to_string( + "atlas/partials/_displayset_search_results.html", + {"displaysets": qs}, + request=request, + ) return HttpResponse(html) @@ -1369,7 +1504,6 @@ class AtlasEditorRequiredMixin(object): raise PermissionDenied("You must be an atlas editor to do this.") # or Http404 - class NormalCaseList(LoginRequiredMixin, FilterView): model = NormalCase template_name = "atlas/normals_list.html" @@ -1395,7 +1529,9 @@ class NormalCaseList(LoginRequiredMixin, FilterView): qs = NormalCase.objects.select_related("case", "examination", "modality") # Restrict similar to CaseFilter: non-editors only see authored or open_access if not self.request.user.groups.filter(name="atlas_editor").exists(): - qs = qs.filter((Q(case__author__id=self.request.user.id) | Q(case__open_access=True))) + qs = qs.filter( + (Q(case__author__id=self.request.user.id) | Q(case__open_access=True)) + ) order_by = self.SORT_CHOICES[self.get_selected_sort()] return qs.order_by(order_by) @@ -1423,6 +1559,7 @@ class NormalCaseList(LoginRequiredMixin, FilterView): context["querystring_without_page_sort"] = params_without_sort.urlencode() return context + @login_required @user_has_case_view_access def case_displaysets(request, pk): @@ -1537,7 +1674,11 @@ def case_search_page(request): page_obj = paginator.get_page(page_number) # Recent cases (authored by current user) - recent_cases = get_cases_available_to_user(request.user).filter(author=request.user).order_by("-created_date")[:5] + recent_cases = ( + get_cases_available_to_user(request.user) + .filter(author=request.user) + .order_by("-created_date")[:5] + ) subspecialty_queryset = Subspecialty.objects.all().order_by("name") @@ -1558,7 +1699,6 @@ def case_search_page(request): ) - @login_required def api_tokens_page(request): """Simple UI to create and revoke API tokens for the logged-in user.""" @@ -1573,11 +1713,15 @@ def api_tokens_page(request): expires = None if expires_days: try: - expires = timezone.now() + timezone.timedelta(days=int(expires_days)) + expires = timezone.now() + timezone.timedelta( + days=int(expires_days) + ) except Exception: expires = None - token, obj = APIToken.create_token(request.user, name=name, scopes=scopes, expires=expires) + token, obj = APIToken.create_token( + request.user, name=name, scopes=scopes, expires=expires + ) created_token = token elif action == "revoke": revoke_id = request.POST.get("revoke_id") @@ -1601,7 +1745,12 @@ def api_tokens_page(request): for t in tokens_qs ] - return render(request, "atlas/api_tokens.html", {"tokens": tokens, "created_token": created_token}) + return render( + request, + "atlas/api_tokens.html", + {"tokens": tokens, "created_token": created_token}, + ) + @login_required @user_has_case_view_access @@ -1624,17 +1773,28 @@ def case_displaysets_add(request, pk): ds.save() form.save_m2m() # HTMX: return the new display set row HTML fragment - html = render_to_string("atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True}) + html = render_to_string( + "atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True} + ) return HttpResponse(html) else: # HTMX: return the form HTML fragment with errors - html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request) + html = render_to_string( + "atlas/partials/displayset_form.html", + {"form": form, "case": case}, + request=request, + ) return HttpResponse(html, status=400) else: # HTMX: return the blank form HTML fragment - html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request) + html = render_to_string( + "atlas/partials/displayset_form.html", + {"form": form, "case": case}, + request=request, + ) return HttpResponse(html) + @login_required @require_http_methods(["GET", "POST"]) def case_displaysets_edit(request, pk): @@ -1657,15 +1817,28 @@ def case_displaysets_edit(request, pk): ds.save() form.save_m2m() # Return updated row HTML - html = render_to_string("atlas/partials/displayset_row.html", {"ds": ds, "can_edit": True}, request=request) + html = render_to_string( + "atlas/partials/displayset_row.html", + {"ds": ds, "can_edit": True}, + request=request, + ) return HttpResponse(html) else: - html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request) + html = render_to_string( + "atlas/partials/displayset_form.html", + {"form": form, "case": case}, + request=request, + ) return HttpResponse(html, status=400) else: - html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request) + html = render_to_string( + "atlas/partials/displayset_form.html", + {"form": form, "case": case}, + request=request, + ) return HttpResponse(html) + @login_required @require_http_methods(["GET"]) def case_displaysets_detail(request, pk): @@ -1680,7 +1853,9 @@ def case_displaysets_detail(request, pk): case = displayset.case edit = request.GET.get("edit") - redirect_url = f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}" + redirect_url = ( + f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}" + ) if edit: redirect_url = f"{redirect_url}&edit={edit}" return redirect(redirect_url) @@ -1699,13 +1874,20 @@ def case_displaysets_modal(request, pk): can_edit = displayset.case.check_user_can_edit(request.user) except Exception: can_edit = False - return render(request, 'atlas/case_displayset_modal.html', {'ds': displayset, 'can_edit': can_edit}) + return render( + request, + "atlas/case_displayset_modal.html", + {"ds": displayset, "can_edit": can_edit}, + ) def displayset_preview(request, pk): """Return a compact display set preview fragment for lightweight popups.""" displayset = get_object_or_404(CaseDisplaySet, pk=pk) - return render(request, 'atlas/partials/_displayset_preview.html', {'ds': displayset}) + return render( + request, "atlas/partials/_displayset_preview.html", {"ds": displayset} + ) + def case_displaysets_delete(request, pk): """Delete a display set and return a replacement row fragment/message for HTMX.""" @@ -1717,32 +1899,41 @@ def case_displaysets_delete(request, pk): if not displayset.case.check_user_can_edit(request.user): raise PermissionDenied("You do not have permission to delete this display set.") displayset.delete() - return HttpResponse("
  • Display set deleted successfully.") + return HttpResponse( + "
  • Display set deleted successfully." + ) def get_case_for_case_detail(pk: int) -> Case: """Return a case with eager-loaded relations used by the case detail page.""" try: case = ( - Case.objects - .select_related() # Add any FK fields if needed + Case.objects.select_related() # Add any FK fields if needed .prefetch_related( Prefetch("author", queryset=User.objects.all()), Prefetch( "seriesdetail_set", - queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related( + queryset=SeriesDetail.objects.select_related("series") + .order_by("sort_order") + .prefetch_related( Prefetch( "series", - queryset=Series.objects.select_related("modality", "examination", "plane", "contrast") - .prefetch_related( - Prefetch( - "findings", - queryset=SeriesFinding.objects.prefetch_related( - "findings", "conditions", "structures" - ) + queryset=Series.objects.select_related( + "modality", "examination", "plane", "contrast" + ).prefetch_related( + Prefetch( + "findings", + queryset=SeriesFinding.objects.prefetch_related( + "findings", "conditions", "structures" ), - Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")), - ) + ), + Prefetch( + "images", + queryset=SeriesImage.objects.filter( + removed=False + ).order_by("position"), + ), + ), ), ), ), @@ -1750,14 +1941,19 @@ def get_case_for_case_detail(pk: int) -> Case: Prefetch("condition", queryset=Condition.objects.all()), Prefetch("procedures", queryset=Procedure.objects.all()), Prefetch("presentation", queryset=Presentation.objects.all()), - Prefetch("pathological_process", queryset=PathologicalProcess.objects.all()), + Prefetch( + "pathological_process", queryset=PathologicalProcess.objects.all() + ), Prefetch("caseresource_set", queryset=CaseResource.objects.all()), Prefetch("casecollection_set", queryset=CaseCollection.objects.all()), - Prefetch("display_sets", queryset=CaseDisplaySet.objects.prefetch_related( - Prefetch("findings", queryset=Finding.objects.all()), - Prefetch("structures", queryset=Structure.objects.all()), - Prefetch("conditions", queryset=Condition.objects.all()), - )), + Prefetch( + "display_sets", + queryset=CaseDisplaySet.objects.prefetch_related( + Prefetch("findings", queryset=Finding.objects.all()), + Prefetch("structures", queryset=Structure.objects.all()), + Prefetch("conditions", queryset=Condition.objects.all()), + ), + ), ) .get(pk=pk) ) @@ -1766,6 +1962,7 @@ def get_case_for_case_detail(pk: int) -> Case: except Case.DoesNotExist: raise Http404("Case not found.") + @login_required @user_has_case_view_access def case_detail(request, pk): @@ -1776,7 +1973,9 @@ def case_detail(request, pk): ordered_series = list(getattr(case, "ordered_series", case.get_ordered_series())) for series in ordered_series: - prefetched_images = getattr(series, "_prefetched_objects_cache", {}).get("images") + prefetched_images = getattr(series, "_prefetched_objects_cache", {}).get( + "images" + ) if prefetched_images is not None: series.visible_image_count = len(prefetched_images) else: @@ -1790,7 +1989,6 @@ def case_detail(request, pk): .prefetch_related("author", "condition", "presentation", "subspecialty")[:5] ) - return render( request, "atlas/case_detail.html", @@ -1863,40 +2061,58 @@ def _get_case_inline_suggestions(case, field_name): examination_text = "" if field_name == "title": - return _dedupe_non_empty([ - conditions, - f"{conditions} presenting with {presentations}" if conditions and presentations else "", - presentations, - f"{modality_text} {examination_text}" if modality_text or examination_text else "", - ])[:4] + return _dedupe_non_empty( + [ + conditions, + f"{conditions} presenting with {presentations}" + if conditions and presentations + else "", + presentations, + f"{modality_text} {examination_text}" + if modality_text or examination_text + else "", + ] + )[:4] if field_name == "description": - return _dedupe_non_empty([ - f"{modality_text} {examination_text}".strip(), - f"Case demonstrating {conditions}" if conditions else "", - f"Key presentation: {presentations}" if presentations else "", - ])[:4] + return _dedupe_non_empty( + [ + f"{modality_text} {examination_text}".strip(), + f"Case demonstrating {conditions}" if conditions else "", + f"Key presentation: {presentations}" if presentations else "", + ] + )[:4] if field_name == "history": - return _dedupe_non_empty([ - f"Presentation: {presentations}." if presentations else "", - f"Relevant background includes {procedures}." if procedures else "", - f"Working diagnosis: {conditions}." if conditions else "", - ])[:4] + return _dedupe_non_empty( + [ + f"Presentation: {presentations}." if presentations else "", + f"Relevant background includes {procedures}." if procedures else "", + f"Working diagnosis: {conditions}." if conditions else "", + ] + )[:4] if field_name == "discussion": - return _dedupe_non_empty([ - f"Imaging findings are most consistent with {conditions}." if conditions else "", - f"Differential considerations include {conditions}." if conditions else "", - "Correlation with clinical history and prior imaging is advised.", - ])[:4] + return _dedupe_non_empty( + [ + f"Imaging findings are most consistent with {conditions}." + if conditions + else "", + f"Differential considerations include {conditions}." + if conditions + else "", + "Correlation with clinical history and prior imaging is advised.", + ] + )[:4] if field_name == "report": - return _dedupe_non_empty([ - f"Findings are in keeping with {conditions}." if conditions else "", - f"Clinical indication: {presentations}." if presentations else "", - "No acute abnormality identified.", - ])[:4] + return _dedupe_non_empty( + [ + f"Findings are in keeping with {conditions}." if conditions else "", + f"Clinical indication: {presentations}." if presentations else "", + "No acute abnormality identified.", + ] + )[:4] return [] @@ -1987,7 +2203,9 @@ def case_inline_field(request, pk, field_name): if field_name == "diagnostic_certainty": display_value = case.get_diagnostic_certainty_display() - select_choices = [(str(choice.value), str(choice.label)) for choice in Case.CertaintyChoices] + select_choices = [ + (str(choice.value), str(choice.label)) for choice in Case.CertaintyChoices + ] selected_values = [] multiple = False elif field_name == "subspecialty": @@ -2002,7 +2220,9 @@ def case_inline_field(request, pk, field_name): selected_values = [] multiple = False - endpoint_url = reverse("atlas:case_inline_field", kwargs={"pk": case.pk, "field_name": field_name}) + endpoint_url = reverse( + "atlas:case_inline_field", kwargs={"pk": case.pk, "field_name": field_name} + ) if show_label: endpoint_url_default = endpoint_url endpoint_url_edit = f"{endpoint_url}?edit=1" @@ -2036,7 +2256,6 @@ def case_inline_field(request, pk, field_name): return HttpResponse(html) - @login_required @require_POST def toggle_case_normal(request, pk): @@ -2101,7 +2320,11 @@ def toggle_case_normal(request, pk): # Try birthdate + studydate try: pbd = getattr(ds, "PatientBirthDate", None) - sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None) + sdate = ( + getattr(ds, "StudyDate", None) + or getattr(ds, "AcquisitionDate", None) + or getattr(ds, "SeriesDate", None) + ) except Exception: pbd = None sdate = None @@ -2132,12 +2355,21 @@ def toggle_case_normal(request, pk): # Render the updated fragment # Recompute flags for the fragment render - is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists() + is_atlas_editor = ( + request.user.is_superuser + or request.user.groups.filter(name="atlas_editor").exists() + ) is_case_author = case.author.filter(id=request.user.id).exists() can_edit = case.check_user_can_edit(request.user) html = render_to_string( "atlas/partials/_normal_toggle.html", - {"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit}, + { + "case": case, + "user": request.user, + "is_atlas_editor": is_atlas_editor, + "is_case_author": is_case_author, + "can_edit": can_edit, + }, request=request, ) return HttpResponse(html) @@ -2169,15 +2401,21 @@ def case_normal_form(request, pk): pa = getattr(ds, "PatientAge", None) if pa: import re + m = re.search(r"(\d+)", str(pa)) if m: age = int(m.group(1)) break pbd = getattr(ds, "PatientBirthDate", None) - sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None) + sdate = ( + getattr(ds, "StudyDate", None) + or getattr(ds, "AcquisitionDate", None) + or getattr(ds, "SeriesDate", None) + ) if pbd and sdate: from datetime import datetime + try: bd = datetime.strptime(str(pbd), "%Y%m%d") sd = datetime.strptime(str(sdate), "%Y%m%d") @@ -2201,6 +2439,7 @@ def case_normal_form(request, pk): # Map inferred age (years) into age_value/unit defaults. If we inferred # a fractional year (from birthdate), prefer months for ages < 2 years. from .models import NormalCase + inferred_age_value = None inferred_age_unit = None try: @@ -2216,23 +2455,29 @@ def case_normal_form(request, pk): inferred_age_value = None inferred_age_unit = None - - form = NormalCaseForm(initial={ - "age_value": inferred_age_value, - "age_unit": inferred_age_unit, - "examination": inferred_examination, - "modality": inferred_modality, - }) + form = NormalCaseForm( + initial={ + "age_value": inferred_age_value, + "age_unit": inferred_age_unit, + "examination": inferred_examination, + "modality": inferred_modality, + } + ) # Log the rendered form HTML so we can verify inputs/names reach the client from loguru import logger + try: form_html = form.as_p() logger.debug("case_normal_form rendered form HTML: {}", form_html) except Exception as e: logger.exception("Failed to render form.as_p(): {}", e) - html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request) + html = render_to_string( + "atlas/partials/_normal_form.html", + {"form": form, "case": case}, + request=request, + ) logger.debug("case_normal_form returning HTML length: {}", len(html)) return HttpResponse(html) @@ -2270,19 +2515,34 @@ def create_case_normal(request, pk): case = get_case_for_case_detail(pk) # Return the updated toggle fragment and close modal via inline script - is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists() + is_atlas_editor = ( + request.user.is_superuser + or request.user.groups.filter(name="atlas_editor").exists() + ) is_case_author = case.author.filter(id=request.user.id).exists() can_edit = case.check_user_can_edit(request.user) toggle_html = render_to_string( "atlas/partials/_normal_toggle.html", - {"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit}, + { + "case": case, + "user": request.user, + "is_atlas_editor": is_atlas_editor, + "is_case_author": is_case_author, + "can_edit": can_edit, + }, request=request, ) - toggle_html += "".format(case.pk) + toggle_html += "".format( + case.pk + ) return HttpResponse(toggle_html) else: # On form errors, re-render the modal with errors - html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request) + html = render_to_string( + "atlas/partials/_normal_form.html", + {"form": form, "case": case}, + request=request, + ) return HttpResponse(html, status=400) @@ -2293,7 +2553,10 @@ def case_normal_edit(request, pk): normal = get_object_or_404(NormalCase, pk=pk) # Permission: allow NormalCase authors or atlas editors/superusers - is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists() + is_atlas_editor = ( + request.user.is_superuser + or request.user.groups.filter(name="atlas_editor").exists() + ) is_normal_author = normal.author.filter(id=request.user.id).exists() if not (is_atlas_editor or is_normal_author): return HttpResponse(status=403) @@ -2319,20 +2582,37 @@ def case_normal_edit(request, pk): can_edit = case.check_user_can_edit(request.user) toggle_html = render_to_string( "atlas/partials/_normal_toggle.html", - {"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_edit": can_edit}, + { + "case": case, + "user": request.user, + "is_atlas_editor": is_atlas_editor, + "is_case_author": is_case_author, + "can_edit": can_edit, + }, request=request, ) - toggle_html += "".format(case.pk) + toggle_html += "".format( + case.pk + ) return HttpResponse(toggle_html) else: - html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": normal.case, "normal": normal}, request=request) + html = render_to_string( + "atlas/partials/_normal_form.html", + {"form": form, "case": normal.case, "normal": normal}, + request=request, + ) return HttpResponse(html, status=400) # GET - render form in modal form = NormalCaseForm(instance=normal) - html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": normal.case, "normal": normal}, request=request) + html = render_to_string( + "atlas/partials/_normal_form.html", + {"form": form, "case": normal.case, "normal": normal}, + request=request, + ) return HttpResponse(html) + @login_required @user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access def series_thumbnail_fail(request, pk): @@ -2348,12 +2628,13 @@ def series_thumbnail(request, pk, fail_loudly=False): return HttpResponse(thumbnail) + @login_required @user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access def series_viewer(request, pk, finding_pk=None): series = get_object_or_404(Series, pk=pk) context = { - "series": series, + "series": series, } return render( @@ -2362,15 +2643,17 @@ def series_viewer(request, pk, finding_pk=None): context, ) + @login_required @user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access def series_detail(request, pk, finding_pk=None): series = get_object_or_404(Series, pk=pk) series_images_with_urls = [ - (image, f"{REMOTE_URL}{image.image.url}") - for image in series.get_images() + (image, f"{REMOTE_URL}{image.image.url}") for image in series.get_images() ] - detected_series_groups = _build_declared_series_groups(series, series_images_with_urls) + detected_series_groups = _build_declared_series_groups( + series, series_images_with_urls + ) can_edit = series.check_user_can_edit(request.user) @@ -2383,15 +2666,17 @@ def series_detail(request, pk, finding_pk=None): series_finding_form = SeriesFindingForm(series_id=series.id) context = { - "series": series, - "series_finding_form": series_finding_form, - "editing_finding": editing_finding, - "can_edit": can_edit, - "detected_series_groups": detected_series_groups, - } + "series": series, + "series_finding_form": series_finding_form, + "editing_finding": editing_finding, + "can_edit": can_edit, + "detected_series_groups": detected_series_groups, + } if can_edit: - context["available_cases"] = Case.objects.filter(author__id=request.user.id) # Adjust the queryset as needed + context["available_cases"] = Case.objects.filter( + author__id=request.user.id + ) # Adjust the queryset as needed # logging.debug(atlas.subspecialty.first().name.all()) return render( @@ -2409,7 +2694,11 @@ def series_images_partial(request, pk): logger.debug(series) images = series.images.filter(removed=False).order_by("pk") logger.debug(images) - return render(request, "atlas/partials/series_images.html", {"series": series, "images": images}) + return render( + request, + "atlas/partials/series_images.html", + {"series": series, "images": images}, + ) @login_required @@ -2462,13 +2751,14 @@ def condition_detail(request, pk): ): can_merge = True - if request.method == "POST" and request.POST.get("action") == "update_relationships": + if ( + request.method == "POST" + and request.POST.get("action") == "update_relationships" + ): if not can_merge: return HttpResponse("Forbidden", status=403) - relationship_form = ConditionRelationshipForm( - request.POST, instance=condition - ) + relationship_form = ConditionRelationshipForm(request.POST, instance=condition) if relationship_form.is_valid(): relationship_form.save() if request.htmx: @@ -2518,12 +2808,14 @@ def condition_detail(request, pk): # to point at our master. If `other` has no canonical, set it. if other.canonical: # move the whole other group (canonical + aliases) to our master - group_master = other.canonical - members = Condition.objects.filter(Q(canonical=group_master) | Q(pk=group_master.pk)) - for m in members: - if m.pk != master.pk: - m.canonical = master - m.save() + group_master = other.canonical + members = Condition.objects.filter( + Q(canonical=group_master) | Q(pk=group_master.pk) + ) + for m in members: + if m.pk != master.pk: + m.canonical = master + m.save() else: # other is standalone; make it an alias of our master other.canonical = master @@ -2538,7 +2830,11 @@ def condition_detail(request, pk): if request.htmx: html = render_to_string( "atlas/partials/_synonyms_list.html", - {"condition": condition, "synonym_form": synonym_form, "can_merge": can_merge}, + { + "condition": condition, + "synonym_form": synonym_form, + "can_merge": can_merge, + }, request=request, ) return HttpResponse(html) @@ -2767,10 +3063,14 @@ def user_collections(request): ct = ContentType.objects.get_for_model(CaseCollection) taken_exams = CidUserExam.objects.filter(user_user=target_user, content_type=ct) in_progress_ids = list( - taken_exams.filter(completed=False).values_list("object_id", flat=True).distinct() + taken_exams.filter(completed=False) + .values_list("object_id", flat=True) + .distinct() ) finished_ids = list( - taken_exams.filter(completed=True).values_list("object_id", flat=True).distinct() + taken_exams.filter(completed=True) + .values_list("object_id", flat=True) + .distinct() ) except Exception: # If anything goes wrong determining taken/finished exams, treat as none @@ -2784,17 +3084,23 @@ def user_collections(request): available_qs = available_collections.exclude(pk__in=taken_all_ids).order_by("name") # In-progress (taken but not completed) - in_progress_qs = CaseCollection.objects.filter(pk__in=list(in_progress_ids)).order_by("name") + in_progress_qs = CaseCollection.objects.filter( + pk__in=list(in_progress_ids) + ).order_by("name") # Finished (completed) - finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by("name") + finished_qs = CaseCollection.objects.filter(pk__in=list(finished_ids)).order_by( + "name" + ) show_hidden = request.GET.get("show_hidden") in ("true", "1") # Get content type for CaseCollection ct_cc = ContentType.objects.get_for_model(CaseCollection) hidden_ids = set( - UserHiddenItem.objects.filter(user=target_user, content_type=ct_cc).values_list("object_id", flat=True) + UserHiddenItem.objects.filter(user=target_user, content_type=ct_cc).values_list( + "object_id", flat=True + ) ) if not show_hidden: @@ -2819,14 +3125,18 @@ def user_collections(request): # If called via HTMX return a small fragment suitable for swapping # into the index page. Otherwise render the full page. - is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true" + is_htmx = ( + getattr(request, "htmx", False) + or request.headers.get("HX-Request", "").lower() == "true" + ) if is_htmx: - html = render_to_string("atlas/partials/_user_collections.html", context, request=request) + html = render_to_string( + "atlas/partials/_user_collections.html", context, request=request + ) return HttpResponse(html) return render(request, "atlas/user_collections.html", context) - # Note: keep the above full-page render for normal requests. If this # endpoint is called by HTMX we prefer returning a small fragment so it # can be swapped into the index page without duplicating layout. @@ -2857,20 +3167,30 @@ def user_related_collections(request): context = {"related_collections": related} - is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true" + is_htmx = ( + getattr(request, "htmx", False) + or request.headers.get("HX-Request", "").lower() == "true" + ) if is_htmx: - html = render_to_string("atlas/partials/_user_related_collections.html", context, request=request) + html = render_to_string( + "atlas/partials/_user_related_collections.html", context, request=request + ) return HttpResponse(html) return render(request, "atlas/user_collections.html", context) + @login_required def collection_options(request): if not request.htmx: return Http404 collections = CaseCollection.objects.filter(author=request.user).order_by("name") - html = render_to_string("atlas/partials/collection_options.html", {"collections": collections}, request=request) + html = render_to_string( + "atlas/partials/collection_options.html", + {"collections": collections}, + request=request, + ) return HttpResponse(html) @@ -2887,17 +3207,17 @@ def collection_case_jump_partial(request, pk: int): casedetails = ( CaseDetail.objects.filter(collection=collection) - .select_related('case') - .order_by('sort_order') + .select_related("case") + .order_by("sort_order") ) try: - current = int(request.GET.get('current', 0) or 0) + current = int(request.GET.get("current", 0) or 0) except Exception: current = 0 - cid = request.GET.get('cid') - passcode = request.GET.get('passcode') + cid = request.GET.get("cid") + passcode = request.GET.get("passcode") try: cid_value = int(cid) if cid is not None else None @@ -2910,19 +3230,18 @@ def collection_case_jump_partial(request, pk: int): ) if cid_value is not None: - answers = CidReportAnswer.objects.filter(cid=cid_value, question__in=casedetails) + answers = CidReportAnswer.objects.filter( + cid=cid_value, question__in=casedetails + ) else: - answers = UserReportAnswer.objects.filter(user=request.user, question__in=casedetails) + answers = UserReportAnswer.objects.filter( + user=request.user, question__in=casedetails + ) answer_case_ids = { - ans.question.case_id - for ans in answers - if (ans.answer or ans.json_answer) - } - answer_by_case_id = { - ans.question.case_id: ans - for ans in answers + ans.question.case_id for ans in answers if (ans.answer or ans.json_answer) } + answer_by_case_id = {ans.question.case_id: ans for ans in answers} latest_reviews = ( cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails]) @@ -2940,7 +3259,12 @@ def collection_case_jump_partial(request, pk: int): if cid is not None: case_url = reverse( "atlas:collection_case_view_take", - kwargs={"pk": collection.pk, "case_number": idx, "cid": cid, "passcode": passcode}, + kwargs={ + "pk": collection.pk, + "case_number": idx, + "cid": cid, + "passcode": passcode, + }, ) else: case_url = reverse( @@ -2964,11 +3288,19 @@ def collection_case_jump_partial(request, pk: int): if review is not None: review_view_url = reverse( "atlas:self_review_detail", - kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id, "pk": review.id}, + kwargs={ + "user_exam_id": cid_user_exam.id, + "case_id": cd.case_id, + "pk": review.id, + }, ) review_edit_url = reverse( "atlas:self_review_update", - kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id, "pk": review.id}, + kwargs={ + "user_exam_id": cid_user_exam.id, + "case_id": cd.case_id, + "pk": review.id, + }, ) jump_items.append( @@ -2981,56 +3313,68 @@ def collection_case_jump_partial(request, pk: int): "self_review_edit_url": review_edit_url, "has_answer": cd.case_id in answer_case_ids, "has_self_review": review is not None, - "is_time_expired": timing_state["effective_time_limit"] is not None and timing_state["case_view_locked"], + "is_time_expired": timing_state["effective_time_limit"] is not None + and timing_state["case_view_locked"], } ) html = render_to_string( - 'atlas/partials/_collection_case_jump.html', + "atlas/partials/_collection_case_jump.html", { - 'collection': collection, - 'casedetails': casedetails, - 'jump_items': jump_items, - 'current': current, - 'cid': cid, - 'passcode': passcode, + "collection": collection, + "casedetails": casedetails, + "jump_items": jump_items, + "current": current, + "cid": cid, + "passcode": passcode, }, request=request, ) return HttpResponse(html) + @login_required def remove_cases_from_collection(request): if not request.htmx: return Http404 - case_ids = request.POST.getlist('selection') - collection_id = request.POST.get('collection_id') + case_ids = request.POST.getlist("selection") + collection_id = request.POST.get("collection_id") if not case_ids or not collection_id: return HttpResponse("No cases selected or collection ID provided.") collection = get_object_or_404(CaseCollection, pk=collection_id) if request.user not in collection.author.all(): - return HttpResponse("You do not have permission to remove cases from this collection.") + return HttpResponse( + "You do not have permission to remove cases from this collection." + ) cases = Case.objects.filter(pk__in=case_ids) for case_ in cases: collection.cases.remove(case_) - return HttpResponse(f"Removed {len(cases)} cases from collection {collection.name} (refresh to see)") + return HttpResponse( + f"Removed {len(cases)} cases from collection {collection.name} (refresh to see)" + ) + @login_required def add_cases_to_collection(request): if not request.htmx: return Http404 - case_ids = request.POST.getlist('selection') - collection_id = request.POST.get('collection_id') + case_ids = request.POST.getlist("selection") + collection_id = request.POST.get("collection_id") if not case_ids or not collection_id: return HttpResponse("No cases selected or collection ID provided.") collection = get_object_or_404(CaseCollection, pk=collection_id) if request.user not in collection.author.all(): - return HttpResponse("You do not have permission to add cases to this collection.") + return HttpResponse( + "You do not have permission to add cases to this collection." + ) cases = Case.objects.filter(pk__in=case_ids) for case_ in cases: collection.cases.add(case_) - return HttpResponse(f"Added {len(cases)} cases to collection {collection.name} (refresh to see)") + return HttpResponse( + f"Added {len(cases)} cases to collection {collection.name} (refresh to see)" + ) + @login_required def set_open_access(request): @@ -3047,12 +3391,17 @@ def set_open_access(request): # This will not let give accurate permission errors if the user does not have permission to edit any of the cases # but it is simpler to just check the first case. if not case.check_user_can_edit(request.user): - return HttpResponse(f"You do not have permission to set case {case.name} as open access.") + return HttpResponse( + f"You do not have permission to set case {case.name} as open access." + ) case.open_access = True case.save() - return HttpResponse(f"Cases {', '.join([case.title for case in cases])} set to open access (refresh to see changes).") + return HttpResponse( + f"Cases {', '.join([case.title for case in cases])} set to open access (refresh to see changes)." + ) + @login_required def remove_open_access(request): @@ -3069,12 +3418,16 @@ def remove_open_access(request): # This will not let give accurate permission errors if the user does not have permission to edit any of the cases # but it is simpler to just check the first case. if not case.check_user_can_edit(request.user): - return HttpResponse(f"You do not have permission to remove open access from case {case.name}.") + return HttpResponse( + f"You do not have permission to remove open access from case {case.name}." + ) case.open_access = False case.save() - return HttpResponse(f"Cases {', '.join([case.title for case in cases])} removed from open access (refresh to see changes).") + return HttpResponse( + f"Cases {', '.join([case.title for case in cases])} removed from open access (refresh to see changes)." + ) def add_case_to_collection(request, collection_id): @@ -3086,7 +3439,9 @@ def add_case_to_collection(request, collection_id): if request.method == "POST": case = get_object_or_404(Case, pk=request.POST["case"]) if request.user not in collection.author.all(): - return HttpResponse("You do not have permission to add cases to this collection.") + return HttpResponse( + "You do not have permission to add cases to this collection." + ) if case in collection.cases.all(): return HttpResponse("Case already in collection") @@ -3100,7 +3455,10 @@ def add_case_to_collection(request, collection_id): return HttpResponse("Case added, but could not render item.") total = collection.casedetail_set.count() - can_edit = request.user.is_superuser or collection.author.filter(pk=request.user.pk).exists() + can_edit = ( + request.user.is_superuser + or collection.author.filter(pk=request.user.pk).exists() + ) rendered = render_to_string( "atlas/partials/case_list_item.html", { @@ -3113,15 +3471,16 @@ def add_case_to_collection(request, collection_id): ) # Also instruct the client to remove this case from any visible search/recent lists remove_script = ( - '' + "" ) return HttpResponse(rendered + remove_script) @@ -3139,7 +3498,12 @@ def add_case_to_collection(request, collection_id): return render( request, "atlas/add_case_to_collection.html", - {"cases": cases, "collection": collection, "recent_cases": recent_cases, "q": ""}, + { + "cases": cases, + "collection": collection, + "recent_cases": recent_cases, + "q": "", + }, ) @@ -3151,7 +3515,12 @@ def case_search(request): q = request.GET.get("q", "").strip() collection_id = request.GET.get("collection") - show_select = str(request.GET.get("show_select", "")).lower() in ("1", "true", "yes", "on") + show_select = str(request.GET.get("show_select", "")).lower() in ( + "1", + "true", + "yes", + "on", + ) collection = None if collection_id: try: @@ -3167,7 +3536,9 @@ def case_search(request): # Restrict recent cases to those authored by the current user and not already in the collection recent_qs = get_cases_available_to_user(request.user).filter(author=request.user) if collection is not None: - recent_qs = recent_qs.exclude(pk__in=collection.cases.values_list("pk", flat=True)) + recent_qs = recent_qs.exclude( + pk__in=collection.cases.values_list("pk", flat=True) + ) recent_cases = recent_qs.order_by("-created_date")[:5] # Exclude recent cases from the main search results to avoid duplication @@ -3250,7 +3621,9 @@ def uploads_hash_search(request): matched_series_filters |= Q(series_instance_uid__icontains=series_query) if study_query: - uploads_filters |= Q(basic_dicom_tags__StudyInstanceUID__icontains=study_query) + uploads_filters |= Q( + basic_dicom_tags__StudyInstanceUID__icontains=study_query + ) imported_filters |= Q(series__study_instance_uid__icontains=study_query) matched_series_filters |= Q(study_instance_uid__icontains=study_query) @@ -3288,7 +3661,9 @@ def uploads_hash_search(request): matched_series_paginator = Paginator(matched_series_qs, 50) matched_series_page_number = request.GET.get("series_page") - matched_series_page_obj = matched_series_paginator.get_page(matched_series_page_number) + matched_series_page_obj = matched_series_paginator.get_page( + matched_series_page_number + ) return render( request, @@ -3415,9 +3790,7 @@ def user_uploads( existing_series_lookup: dict[int, Series] = {} case_to_study_uids = defaultdict(set) uploaded_hashes = { - image_hash - for hash_set in series_hashes.values() - for image_hash in hash_set + image_hash for hash_set in series_hashes.values() for image_hash in hash_set } hash_to_uploaded_uids = defaultdict(set) for uploaded_uid, hash_set in series_hashes.items(): @@ -3425,10 +3798,9 @@ def user_uploads( hash_to_uploaded_uids[image_hash].add(uploaded_uid) if uploaded_series_uids: - existing_series = ( - Series.objects.filter(series_instance_uid__in=uploaded_series_uids) - .prefetch_related("case") - ) + existing_series = Series.objects.filter( + series_instance_uid__in=uploaded_series_uids + ).prefetch_related("case") existing_study_series = Series.objects.filter( study_instance_uid__in=uploaded_study_uids @@ -3458,8 +3830,14 @@ def user_uploads( continue existing_series_lookup[existing.pk] = existing - for uploaded_uid, uploaded_study_uid in uploaded_series_uid_to_study_uid.items(): - if uploaded_study_uid and uploaded_study_uid == existing.study_instance_uid: + for ( + uploaded_uid, + uploaded_study_uid, + ) in uploaded_series_uid_to_study_uid.items(): + if ( + uploaded_study_uid + and uploaded_study_uid == existing.study_instance_uid + ): uploaded_uid_to_existing_series_ids[uploaded_uid].add(existing.pk) for case_obj in existing.case.all(): @@ -3485,7 +3863,9 @@ def user_uploads( image.image_blake3_hash, set() ) for uploaded_uid in matched_uploaded_uids: - uploaded_uid_to_existing_series_ids[uploaded_uid].add(image.series.pk) + uploaded_uid_to_existing_series_ids[uploaded_uid].add( + image.series.pk + ) for case_obj in image.series.case.all(): if not case_obj.check_user_can_edit(request.user): continue @@ -3536,13 +3916,18 @@ def user_uploads( matched_case_counts.items(), key=lambda item: ( item[1], - len(case_to_series_uids.get(item[0], set()) & study_uploaded_uids), + len( + case_to_series_uids.get(item[0], set()) + & study_uploaded_uids + ), -item[0], ), ) study_data["preferred_case_id"] = preferred_case_id - related_case_ids = set(case_to_series_uids.keys()) | set(case_to_study_uids.keys()) + related_case_ids = set(case_to_series_uids.keys()) | set( + case_to_study_uids.keys() + ) for case_pk in related_case_ids: imported_uids = case_to_series_uids.get(case_pk, set()) overlap = study_uploaded_uids & imported_uids @@ -3578,10 +3963,12 @@ def user_uploads( study_data["preferred_case_id"] = sorted_suggestions[0]["case"].pk # Now group the processed studies by PatientID - patient_groups = defaultdict(lambda: { - "studies": [], - "min_date": "99999999", - }) + patient_groups = defaultdict( + lambda: { + "studies": [], + "min_date": "99999999", + } + ) for study_uid, study_data in grouped_series: patient_id = "Unknown Patient" @@ -3604,7 +3991,7 @@ def user_uploads( } for patient_id, pg in patient_groups.items() ], - key=lambda x: x["min_date"] or "" + key=lambda x: x["min_date"] or "", ) case = False @@ -3699,7 +4086,9 @@ def uploads_import_htmx(request, case_id: int | None = None): { "atlas:uploadsImported": { "imported_count": len(imported_rows), - "requested_count": len(selected_uids) if selected_uids else len(imported_rows), + "requested_count": len(selected_uids) + if selected_uids + else len(imported_rows), } } ) @@ -3719,7 +4108,9 @@ def uploads_import_case_series_htmx(request): - Links each newly created case to the previous one with previous_case. - Imports the selected series for each study into its corresponding case. """ - selected_uids = [uid.strip() for uid in request.POST.getlist("selection") if uid.strip()] + selected_uids = [ + uid.strip() for uid in request.POST.getlist("selection") if uid.strip() + ] if not selected_uids: return HttpResponse( '
    Select at least one series before creating a case chain.
    ', @@ -3733,10 +4124,9 @@ def uploads_import_case_series_htmx(request): status=400, ) - dicoms = ( - UncategorisedDicom.objects.filter(user=request.user, series_instance_uid__in=selected_uids) - .select_related("user") - ) + dicoms = UncategorisedDicom.objects.filter( + user=request.user, series_instance_uid__in=selected_uids + ).select_related("user") if not dicoms.exists(): return HttpResponse( '
    No pending uploads were found for the selected series.
    ', @@ -3776,7 +4166,13 @@ def uploads_import_case_series_htmx(request): for field in Case._meta.concrete_fields if field.name not in {"id", "previous_case"} } - m2m_field_names = ["subspecialty", "condition", "presentation", "procedures", "pathological_process"] + m2m_field_names = [ + "subspecialty", + "condition", + "presentation", + "procedures", + "pathological_process", + ] created_cases = [] import_results = [] @@ -3800,7 +4196,9 @@ def uploads_import_case_series_htmx(request): for field_name in m2m_field_names: getattr(case, field_name).set(form.cleaned_data.get(field_name, [])) - imported_rows = import_dicoms_helper(request, case_id=case.pk, dicoms=study_dicoms) + imported_rows = import_dicoms_helper( + request, case_id=case.pk, dicoms=study_dicoms + ) created_cases.append(case) import_results.append((case, study_uid, imported_rows)) previous_case = case @@ -3817,11 +4215,11 @@ def uploads_import_case_series_htmx(request): imported_series_count = sum(len(rows) for _, _, rows in import_results) html = ( '
    ' - f'Created {len(created_cases)} linked case{ "s" if len(created_cases) != 1 else "" } and imported {imported_series_count} series.' - '
    ' + f"Created {len(created_cases)} linked case{'s' if len(created_cases) != 1 else ''} and imported {imported_series_count} series." + "" '' + "" ) response = HttpResponse(html) response["HX-Trigger"] = json.dumps( @@ -3911,7 +4309,9 @@ class ResourceCreate(LoginRequiredMixin, CreateView): return super().form_valid(form) -class ResourceView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class ResourceView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = Resource table_class = ResourceTable template_name = "atlas/resource_list.html" @@ -3922,11 +4322,16 @@ class ResourceView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTa qs = Resource.objects.all() # Atlas editors and superusers see everything - if self.request.user.is_superuser or self.request.user.groups.filter(name="atlas_editor").exists(): + if ( + self.request.user.is_superuser + or self.request.user.groups.filter(name="atlas_editor").exists() + ): return qs # Otherwise only show open access resources or those authored by the user - return qs.filter(Q(open_access=True) | Q(author__id=self.request.user.id)).distinct() + return qs.filter( + Q(open_access=True) | Q(author__id=self.request.user.id) + ).distinct() class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView): @@ -4059,7 +4464,7 @@ class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateVi def get_context_data(self, **kwargs): context = super(CaseCollectionUpdate, self).get_context_data(**kwargs) - #if self.request.POST: + # if self.request.POST: # context["case_formset"] = CaseCollectionCaseFormSet( # self.request.POST, # self.request.FILES, @@ -4067,7 +4472,7 @@ class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateVi # form_kwargs={"user": self.request.user, "collection": self.object}, # ) # context["case_formset"].full_clean() - #else: + # else: # context["case_formset"] = CaseCollectionCaseFormSet( # instance=self.object, # form_kwargs={"user": self.request.user, "collection": self.object}, @@ -4184,6 +4589,7 @@ class QuestionSchemaDelete(AuthorOrCheckerRequiredMixin, DeleteView): model = QuestionSchema template_name = "confirm_delete.html" + class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView): model = Finding form_class = FindingForm @@ -4234,11 +4640,11 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin): def get_context_data(self, **kwargs): context = super(AtlasCreateBase, self).get_context_data(**kwargs) if self.request.POST: - #context["series_formset"] = SeriesFormSet( + # context["series_formset"] = SeriesFormSet( # self.request.POST, # self.request.FILES, # form_kwargs={"user": self.request.user}, - #) + # ) context["casedifferential_formset"] = CaseDifferentialFormSet( self.request.POST, self.request.FILES ) @@ -4247,14 +4653,14 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin): self.request.FILES, form_kwargs={"user": self.request.user}, ) - #context["series_formset"].full_clean() + # context["series_formset"].full_clean() context["casedifferential_formset"].full_clean() context["caseresource_formset"].full_clean() else: # context["series_formset"] = SeriesFormSet(instance=Case.objects.filter(author__id=self.request.user.id), queryset=Series.objects.filter(author__id=self.request.user.id)) - #context["series_formset"] = SeriesFormSet( + # context["series_formset"] = SeriesFormSet( # form_kwargs={"user": self.request.user} - #) + # ) context["casedifferential_formset"] = CaseDifferentialFormSet() context["caseresource_formset"] = CaseResourceFormSet( form_kwargs={"user": self.request.user} @@ -4274,17 +4680,16 @@ class AtlasCreateBase(RevisionMixin, LoginRequiredMixin): # CimarAPI().get_study_by_uuid(self.object.cimar_uuid) context = self.get_context_data(form=form) - #series_formset = context["series_formset"] + # series_formset = context["series_formset"] casedifferential_formset = context["casedifferential_formset"] caseresource_formset = context["caseresource_formset"] if ( - #series_formset.is_valid() - casedifferential_formset.is_valid() - and caseresource_formset.is_valid() + # series_formset.is_valid() + casedifferential_formset.is_valid() and caseresource_formset.is_valid() ): response = super().form_valid(form) - #series_formset.instance = self.object - #series_formset.save() + # series_formset.instance = self.object + # series_formset.save() casedifferential_formset.instance = self.object casedifferential_formset.save() caseresource_formset.instance = self.object @@ -4332,8 +4737,7 @@ class AtlasUpdate( # #initial = {'date_of_death': '05/01/2018'} # exclude = [ 'created_date', 'published_date' ] - - #def get_object(self, queryset=None): + # def get_object(self, queryset=None): # # Prefetch all related objects needed for the form and formsets # qs = self.model.objects.select_related( # # Add all FK fields here @@ -4359,12 +4763,12 @@ class AtlasUpdate( def get_context_data(self, **kwargs): context = super(AtlasUpdate, self).get_context_data(**kwargs) if self.request.POST: - #context["series_formset"] = SeriesFormSet( + # context["series_formset"] = SeriesFormSet( # self.request.POST, # self.request.FILES, # instance=self.object, # form_kwargs={"user": self.request.user}, - #) + # ) context["casedifferential_formset"] = CaseDifferentialFormSet( self.request.POST, self.request.FILES, instance=self.object ) @@ -4374,13 +4778,13 @@ class AtlasUpdate( instance=self.object, form_kwargs={"user": self.request.user}, ) - #context["series_formset"].full_clean() + # context["series_formset"].full_clean() context["casedifferential_formset"].full_clean() context["caseresource_formset"].full_clean() else: - #context["series_formset"] = SeriesFormSet( + # context["series_formset"] = SeriesFormSet( # instance=self.object, form_kwargs={"user": self.request.user} - #) + # ) context["casedifferential_formset"] = CaseDifferentialFormSet( instance=self.object ) @@ -4397,18 +4801,17 @@ class AtlasUpdate( form.instance.author.add(self.request.user.id) context = self.get_context_data(form=form) - #series_formset = context["series_formset"] + # series_formset = context["series_formset"] casedifferential_formset = context["casedifferential_formset"] caseresource_formset = context["caseresource_formset"] # logger.debug(formset.is_valid()) if ( - #series_formset.is_valid() - casedifferential_formset.is_valid() - and caseresource_formset.is_valid() + # series_formset.is_valid() + casedifferential_formset.is_valid() and caseresource_formset.is_valid() ): response = super().form_valid(form) - #series_formset.instance = self.object - #series_formset.save() + # series_formset.instance = self.object + # series_formset.save() casedifferential_formset.instance = self.object casedifferential_formset.save() caseresource_formset.instance = self.object @@ -4418,8 +4821,10 @@ class AtlasUpdate( else: return super().form_invalid(form) + class CollectionCaseUpdate( - RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView): + RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView +): model = CaseCollection form_class = CaseCollectionUpdateCaseForm template_name = "atlas/casecollection_update_case.html" @@ -4429,7 +4834,7 @@ class CollectionCaseUpdate( kwargs.update({"user": self.request.user, "collection": self.object}) return kwargs - + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if self.request.POST: @@ -4473,7 +4878,10 @@ class AtlasSeriesUpdate( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - is_atlas_editor = self.request.user.groups.filter(name="atlas_editor").exists() or self.request.user.is_superuser + is_atlas_editor = ( + self.request.user.groups.filter(name="atlas_editor").exists() + or self.request.user.is_superuser + ) form_kwargs = {"user": self.request.user, "is_atlas_editor": is_atlas_editor} if not is_atlas_editor: @@ -4481,9 +4889,13 @@ class AtlasSeriesUpdate( else: queryset = Series.objects.all() queryset = queryset.select_related("modality").prefetch_related( - "examination", "plane", "contrast", - Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")) - + "examination", + "plane", + "contrast", + Prefetch( + "images", + queryset=SeriesImage.objects.filter(removed=False).order_by("position"), + ), ) if self.request.POST: @@ -4523,6 +4935,7 @@ class AtlasSeriesUpdate( ) return qs.get(pk=self.kwargs["pk"]) + def add_collection_to_case_form(request, case_id): if request.method == "POST": form = AddCollectionToCaseForm(request.POST, user=request.user) @@ -4543,7 +4956,10 @@ def add_collection_to_case_form(request, case_id): case = None # If called via HTMX (inline from case view) return a compact fragment - is_htmx = getattr(request, "htmx", False) or request.headers.get("HX-Request", "").lower() == "true" + is_htmx = ( + getattr(request, "htmx", False) + or request.headers.get("HX-Request", "").lower() == "true" + ) if is_htmx: # Provide the 10 most recently updated collections for this user by default user = request.user @@ -4554,7 +4970,9 @@ def add_collection_to_case_form(request, case_id): ) # Exclude collections that already contain the case (if resolved) if case is not None: - recent_base = recent_base.exclude(pk__in=case.casecollection_set.values_list("pk", flat=True)) + recent_base = recent_base.exclude( + pk__in=case.casecollection_set.values_list("pk", flat=True) + ) recent_qs = recent_base[:10] html = render_to_string( @@ -4583,10 +5001,12 @@ def collection_inline_search(request, case_id): q = request.GET.get("q", "").strip() user = request.user - base_qs = ( - CaseCollection.objects.filter(Q(author=user) | Q(markers=user) | Q(valid_user_users=user) | Q(open_access=True)) - .distinct() - ) + base_qs = CaseCollection.objects.filter( + Q(author=user) + | Q(markers=user) + | Q(valid_user_users=user) + | Q(open_access=True) + ).distinct() try: case = Case.objects.get(pk=case_id) @@ -4595,7 +5015,9 @@ def collection_inline_search(request, case_id): # Exclude collections already containing the case if case is not None: - base_qs = base_qs.exclude(pk__in=case.casecollection_set.values_list("pk", flat=True)) + base_qs = base_qs.exclude( + pk__in=case.casecollection_set.values_list("pk", flat=True) + ) if q: results = base_qs.filter(name__icontains=q).order_by("-updated")[:30] @@ -4622,7 +5044,9 @@ def add_collection_to_case_inline(request, case_id, collection_id): # Permission: user must be able to edit the case if not case.check_user_can_edit(request.user): - return HttpResponse("You do not have permission to add collections to this case.", status=403) + return HttpResponse( + "You do not have permission to add collections to this case.", status=403 + ) # Add the association if collection in case.casecollection_set.all(): @@ -4633,20 +5057,25 @@ def add_collection_to_case_inline(request, case_id, collection_id): # Render a list item to insert into the case's collection list rendered = render_to_string( "atlas/partials/collection_list_item.html", - {"collection": collection, "case": case, "can_edit": case.check_user_can_edit(request.user)}, + { + "collection": collection, + "case": case, + "can_edit": case.check_user_can_edit(request.user), + }, request=request, ) # Also instruct the client to remove the collection entry from the inline search list remove_script = ( - '' + "" ) return HttpResponse(rendered + remove_script) @@ -4692,7 +5121,10 @@ class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView): else: pass - initial_data = model_to_dict(old_object, exclude=["id", "previous_case", "history", "discussion", "report"]) + initial_data = model_to_dict( + old_object, + exclude=["id", "previous_case", "history", "discussion", "report"], + ) return initial_data @@ -4746,7 +5178,9 @@ def get_examination_id(request): return HttpResponse("/") -class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class CaseView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = Case table_class = CaseTable template_name = "atlas/case_view.html" @@ -4788,7 +5222,9 @@ def case_admin_overview(request): @staff_member_required def case_admin_recent_cases_partial(request): """HTMX partial for recently added cases in the admin overview.""" - recent_cases = Case.objects.prefetch_related("author").order_by("-created_date")[:30] + recent_cases = Case.objects.prefetch_related("author").order_by("-created_date")[ + :30 + ] return render( request, "atlas/partials/_case_admin_recent_cases.html", @@ -4861,15 +5297,19 @@ def cases_by_size(request): except Exception: total = 0 series_count = c.series.count() - image_count = sum(s.images.filter(removed=False).count() for s in c.series.all()) + image_count = sum( + s.images.filter(removed=False).count() for s in c.series.all() + ) if total >= min_size: - rows.append({ - "case": c, - "total": total, - "series_count": series_count, - "image_count": image_count, - "human_total": human(total), - }) + rows.append( + { + "case": c, + "total": total, + "series_count": series_count, + "image_count": image_count, + "human_total": human(total), + } + ) rows.sort(key=lambda r: r["total"], reverse=True) @@ -4891,7 +5331,9 @@ def cases_by_size(request): return render(request, "atlas/cases_by_size.html", context) -class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class SeriesView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = Series table_class = SeriesTable template_name = "atlas/series_view.html" @@ -4899,7 +5341,9 @@ class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTabl filterset_class = SeriesFilter -class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class ConditionView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = Condition table_class = ConditionTable template_name = "atlas/view.html" @@ -4914,11 +5358,15 @@ class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleT ctx["create_url"] = None # Provide verbose names to templates without accessing _meta in templates ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None) - ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None) + ctx["model_verbose_name_plural"] = getattr( + self.model._meta, "verbose_name_plural", None + ) return ctx -class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class QuestionSchemaView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = QuestionSchema table_class = QuestionSchemaTable template_name = "atlas/schema_table_view.html" @@ -4926,7 +5374,9 @@ class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, Si filterset_class = QuestionSchemaFilter -class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class SubspecialtyView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = Subspecialty table_class = SubspecialtyTable template_name = "atlas/view.html" @@ -4940,11 +5390,15 @@ class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, Sing except NoReverseMatch: ctx["create_url"] = None ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None) - ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None) + ctx["model_verbose_name_plural"] = getattr( + self.model._meta, "verbose_name_plural", None + ) return ctx -class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class PresentationView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = Presentation table_class = PresentationTable template_name = "atlas/view.html" @@ -4958,13 +5412,17 @@ class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, Sing except NoReverseMatch: ctx["create_url"] = None ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None) - ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None) + ctx["model_verbose_name_plural"] = getattr( + self.model._meta, "verbose_name_plural", None + ) return ctx -class ProcedureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, ListView): +class ProcedureView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, ListView +): model = Procedure - table_class = __import__('atlas.tables', fromlist=['ProcedureTable']).ProcedureTable + table_class = __import__("atlas.tables", fromlist=["ProcedureTable"]).ProcedureTable template_name = "atlas/view.html" def get_context_data(self, **kwargs): @@ -4974,7 +5432,9 @@ class ProcedureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleT except NoReverseMatch: ctx["create_url"] = None ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None) - ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None) + ctx["model_verbose_name_plural"] = getattr( + self.model._meta, "verbose_name_plural", None + ) return ctx @@ -4982,14 +5442,20 @@ class ProcedureCreate(RevisionMixin, LoginRequiredMixin, CreateView): model = Procedure form_class = ProcedureForm template_name = "atlas/procedure_form.html" + def dispatch(self, request, *args, **kwargs): try: - logger.debug("ProcedureCreate dispatch: method=%s, user=%s", request.method, request.user) - if request.method == 'POST': + logger.debug( + "ProcedureCreate dispatch: method=%s, user=%s", + request.method, + request.user, + ) + if request.method == "POST": logger.debug("ProcedureCreate POST data: %s", request.POST.dict()) except Exception: logger.exception("Error logging ProcedureCreate dispatch") return super().dispatch(request, *args, **kwargs) + def form_valid(self, form): self.object = form.save() return super().form_valid(form) @@ -5009,6 +5475,7 @@ class ProcedureUpdate(RevisionMixin, LoginRequiredMixin, UpdateView): model = Procedure form_class = ProcedureForm template_name = "atlas/procedure_form.html" + def form_valid(self, form): self.object = form.save() return super().form_valid(form) @@ -5029,7 +5496,9 @@ class ProcedureDelete(RevisionMixin, LoginRequiredMixin, DeleteView): template_name = "confirm_delete.html" -class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class PathologicalProcessView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = PathologicalProcess table_class = PathologicalProcessTable template_name = "atlas/view.html" @@ -5043,11 +5512,15 @@ class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixi except NoReverseMatch: ctx["create_url"] = None ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None) - ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None) + ctx["model_verbose_name_plural"] = getattr( + self.model._meta, "verbose_name_plural", None + ) return ctx -class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class StructureView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = Structure table_class = StructureTable template_name = "atlas/view.html" @@ -5061,11 +5534,15 @@ class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleT except NoReverseMatch: ctx["create_url"] = None ctx["model_verbose_name"] = getattr(self.model._meta, "verbose_name", None) - ctx["model_verbose_name_plural"] = getattr(self.model._meta, "verbose_name_plural", None) + ctx["model_verbose_name_plural"] = getattr( + self.model._meta, "verbose_name_plural", None + ) return ctx -class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView): +class FindingView( + LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView +): model = Finding table_class = FindingTable template_name = "atlas/view.html" @@ -5373,14 +5850,16 @@ def series_split_by_tag(request, pk): if request.method == "GET": analyze_requested = request.GET.get("analyze") == "1" - split_options = _analyse_series_split_options(series) if analyze_requested else [] + split_options = ( + _analyse_series_split_options(series) if analyze_requested else [] + ) return render( request, "atlas/partials/_series_split_by_tag.html", { - "series": series, - "split_options": split_options, - "analyze_requested": analyze_requested, + "series": series, + "split_options": split_options, + "analyze_requested": analyze_requested, }, ) @@ -5392,9 +5871,7 @@ def series_split_by_tag(request, pk): # Support hex tuple notation e.g. "(0008,0008)" if dicom_tag.startswith("(") and dicom_tag.endswith(")"): - dicom_tag = tuple( - [hex(int(i.strip(), 16)) for i in dicom_tag[1:-1].split(",")] - ) + dicom_tag = tuple([hex(int(i.strip(), 16)) for i in dicom_tag[1:-1].split(",")]) image_map = defaultdict(list) @@ -5452,11 +5929,15 @@ def case_download(request, pk): case = get_object_or_404(Case, pk=pk) # Only allow superusers or case authors - if not (request.user.is_superuser or case.author.filter(id=request.user.id).exists()): + if not ( + request.user.is_superuser or case.author.filter(id=request.user.id).exists() + ): raise PermissionDenied() temp_file = ContentFile(b"", name=f"case_{case.pk}.zip") - with zipfile.ZipFile(temp_file, mode="w", compression=zipfile.ZIP_DEFLATED) as zip_file: + with zipfile.ZipFile( + temp_file, mode="w", compression=zipfile.ZIP_DEFLATED + ) as zip_file: metadata = { "case": { "id": case.pk, @@ -5471,10 +5952,14 @@ def case_download(request, pk): "modified_date": getattr(case, "modified_date", None), "presentation": [str(p) for p in case.presentation.all()], "subspecialty": [str(s) for s in case.subspecialty.all()], - "pathological_process": [str(p) for p in case.pathological_process.all()], + "pathological_process": [ + str(p) for p in case.pathological_process.all() + ], "procedures": [str(p) for p in case.procedures.all()], "conditions": [str(c) for c in case.condition.all()], - "case_size_bytes": case.get_total_series_images_size() if hasattr(case, "get_total_series_images_size") else None, + "case_size_bytes": case.get_total_series_images_size() + if hasattr(case, "get_total_series_images_size") + else None, }, "series": [], } @@ -5501,7 +5986,9 @@ def case_download(request, pk): fname = os.path.split(file_obj.name)[-1] arcname = f"series_{series.pk}/{fname}" zip_file.writestr(arcname, file_obj.read()) - series_entry["images"].append({"filename": fname, "position": img.position}) + series_entry["images"].append( + {"filename": fname, "position": img.position} + ) except Exception: # skip missing files continue @@ -5515,9 +6002,7 @@ def case_download(request, pk): temp_file.seek(0) response = HttpResponse(temp_file, content_type="application/zip") - response["Content-Disposition"] = ( - "attachment; filename=%s" % f"case_{case.pk}.zip" - ) + response["Content-Disposition"] = "attachment; filename=%s" % f"case_{case.pk}.zip" response["Content-Length"] = file_size return response @@ -5534,9 +6019,9 @@ def create_series_findings(request): structure_ids = json.loads(request.POST.get("structures")) condition_ids = json.loads(request.POST.get("conditions")) description = request.POST.get("description") - #annotation_json = request.POST.get("annotation_json") - #viewport_json = request.POST.get("viewport_json") - #current_image_id_index = request.POST.get("current_image_id_index") + # annotation_json = request.POST.get("annotation_json") + # viewport_json = request.POST.get("viewport_json") + # current_image_id_index = request.POST.get("current_image_id_index") annotation_json_3d = request.POST.get("annotation_json_3d") viewer_state_3d = request.POST.get("viewer_state_3d") @@ -5549,20 +6034,20 @@ def create_series_findings(request): sf = get_object_or_404(SeriesFinding, pk=series_finding_id) sf.series = series sf.description = description - #sf.annotation_json = annotation_json - #sf.viewport_json = viewport_json + # sf.annotation_json = annotation_json + # sf.viewport_json = viewport_json else: sf = SeriesFinding.objects.create( series=series, description=description, - #annotation_json=annotation_json, - #viewport_json=viewport_json, + # annotation_json=annotation_json, + # viewport_json=viewport_json, ) sf.findings.set(findings) sf.structures.set(structures) sf.conditions.set(conditions) - #sf.current_image_id_index = current_image_id_index + # sf.current_image_id_index = current_image_id_index sf.annotation_json_3d = annotation_json_3d sf.viewer_state_3d = viewer_state_3d sf.save() @@ -5611,7 +6096,11 @@ class CaseCollectionPrerequisiteAutocomplete(autocomplete.Select2QuerySetView): if not self.request.user.is_authenticated: return CaseCollection.objects.none() - qs = CaseCollection.objects.filter(author=self.request.user).distinct().order_by("name") + qs = ( + CaseCollection.objects.filter(author=self.request.user) + .distinct() + .order_by("name") + ) if self.q: try: @@ -5692,7 +6181,7 @@ class StructureAutocomplete(autocomplete.Select2QuerySetView): return Structure.objects.none() return qs - + def has_add_permission(self, request): """Allow any authenticated user to create new Structure entries. @@ -5739,7 +6228,14 @@ def categories_list(request): results = None if query: - from .models import Condition, Finding, Structure, Subspecialty, Presentation, Procedure + from .models import ( + Condition, + Finding, + Structure, + Subspecialty, + Presentation, + Procedure, + ) # Helper to conditionally populate each result list only when the # corresponding type is requested (or when no types filter provided). @@ -5747,15 +6243,39 @@ def categories_list(request): return not types or name in types results = { - "conditions": Condition.objects.filter(name__icontains=query).order_by("name") if include("conditions") else (), - "findings": Finding.objects.filter(name__icontains=query).order_by("name") if include("findings") else (), - "structures": Structure.objects.filter(name__icontains=query).order_by("name") if include("structures") else (), - "subspecialties": Subspecialty.objects.filter(name__icontains=query).order_by("name") if include("subspecialties") else (), - "presentations": Presentation.objects.filter(name__icontains=query).order_by("name") if include("presentations") else (), - "procedures": Procedure.objects.filter(name__icontains=query).order_by("name") if include("procedures") else (), + "conditions": Condition.objects.filter(name__icontains=query).order_by( + "name" + ) + if include("conditions") + else (), + "findings": Finding.objects.filter(name__icontains=query).order_by("name") + if include("findings") + else (), + "structures": Structure.objects.filter(name__icontains=query).order_by( + "name" + ) + if include("structures") + else (), + "subspecialties": Subspecialty.objects.filter( + name__icontains=query + ).order_by("name") + if include("subspecialties") + else (), + "presentations": Presentation.objects.filter( + name__icontains=query + ).order_by("name") + if include("presentations") + else (), + "procedures": Procedure.objects.filter(name__icontains=query).order_by( + "name" + ) + if include("procedures") + else (), } - return render(request, "atlas/categories_list.html", {"query": query, "results": results}) + return render( + request, "atlas/categories_list.html", {"query": query, "results": results} + ) @login_required @@ -5766,23 +6286,63 @@ def categories_search_partial(request): """ query = request.GET.get("q", "").strip() types = request.GET.getlist("types") - results = {"conditions": (), "findings": (), "structures": (), "subspecialties": (), "presentations": (), "procedures": ()} + results = { + "conditions": (), + "findings": (), + "structures": (), + "subspecialties": (), + "presentations": (), + "procedures": (), + } if query: - from .models import Condition, Finding, Structure, Subspecialty, Presentation, Procedure + from .models import ( + Condition, + Finding, + Structure, + Subspecialty, + Presentation, + Procedure, + ) def include(name: str) -> bool: return not types or name in types results = { - "conditions": Condition.objects.filter(name__icontains=query).order_by("name") if include("conditions") else (), - "findings": Finding.objects.filter(name__icontains=query).order_by("name") if include("findings") else (), - "structures": Structure.objects.filter(name__icontains=query).order_by("name") if include("structures") else (), - "subspecialties": Subspecialty.objects.filter(name__icontains=query).order_by("name") if include("subspecialties") else (), - "presentations": Presentation.objects.filter(name__icontains=query).order_by("name") if include("presentations") else (), - "procedures": Procedure.objects.filter(name__icontains=query).order_by("name") if include("procedures") else (), + "conditions": Condition.objects.filter(name__icontains=query).order_by( + "name" + ) + if include("conditions") + else (), + "findings": Finding.objects.filter(name__icontains=query).order_by("name") + if include("findings") + else (), + "structures": Structure.objects.filter(name__icontains=query).order_by( + "name" + ) + if include("structures") + else (), + "subspecialties": Subspecialty.objects.filter( + name__icontains=query + ).order_by("name") + if include("subspecialties") + else (), + "presentations": Presentation.objects.filter( + name__icontains=query + ).order_by("name") + if include("presentations") + else (), + "procedures": Procedure.objects.filter(name__icontains=query).order_by( + "name" + ) + if include("procedures") + else (), } - return render(request, "atlas/_categories_search_results.html", {"query": query, "results": results}) + return render( + request, + "atlas/_categories_search_results.html", + {"query": query, "results": results}, + ) # def collection_view(request): @@ -5803,7 +6363,7 @@ class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView): def collection_viva(request, pk): collection = get_object_or_404(CaseCollection, pk=pk) - #cases = ( + # cases = ( # collection.cases.all() # .order_by("casedetail__sort_order") # .prefetch_related( @@ -5813,7 +6373,7 @@ def collection_viva(request, pk): # "series__plane", # "series__examination", # ) - #) + # ) # Prefetch and annotate aggressively to avoid N+1 queries in the viva view. # - Prefetch `case__series` using the ordering from SeriesDetail @@ -5827,8 +6387,7 @@ def collection_viva(request, pk): ) casedetails = ( - CaseDetail.objects - .filter(collection=collection) + CaseDetail.objects.filter(collection=collection) .select_related("case") .order_by("sort_order") .prefetch_related( @@ -5843,7 +6402,6 @@ def collection_viva(request, pk): .annotate(series_count=Count("case__seriesdetail")) ) - return render( request, "atlas/collection_viva.html", @@ -5866,27 +6424,34 @@ def collection_viva_local(request, pk): from django.db.models import Prefetch as _Prefetch series_images_qs = ( - SeriesImage.objects - .filter(removed=False) + SeriesImage.objects.filter(removed=False) .only("id", "series_id", "image", "removed", "position") .order_by("position") ) series_qs = ( - Series.objects - .select_related("modality", "examination", "plane", "contrast") + Series.objects.select_related("modality", "examination", "plane", "contrast") .order_by("seriesdetail__sort_order") - .prefetch_related(_Prefetch("images", queryset=series_images_qs, to_attr="prefetched_images")) + .prefetch_related( + _Prefetch("images", queryset=series_images_qs, to_attr="prefetched_images") + ) ) casedetails = list( - CaseDetail.objects - .filter(collection=collection) + CaseDetail.objects.filter(collection=collection) .select_related("case") .order_by("sort_order") .prefetch_related( _Prefetch("case__series", queryset=series_qs, to_attr="prefetched_series"), - _Prefetch("case__caseresource_set", queryset=CaseResource.objects.select_related("resource"), to_attr="prefetched_case_resources"), - _Prefetch("case__display_sets", queryset=CaseDisplaySet.objects.all(), to_attr="prefetched_display_sets"), + _Prefetch( + "case__caseresource_set", + queryset=CaseResource.objects.select_related("resource"), + to_attr="prefetched_case_resources", + ), + _Prefetch( + "case__display_sets", + queryset=CaseDisplaySet.objects.all(), + to_attr="prefetched_display_sets", + ), ) .annotate(series_count=Count("case__seriesdetail")) ) @@ -5904,11 +6469,14 @@ def collection_viva_local(request, pk): continue visible_prefetched_series.append(series) prefetched_images = getattr(series, "prefetched_images", []) - images_nested.append([ - img.image.url - for img in prefetched_images - if not getattr(img, "removed", False) and getattr(img, "image", None) - ]) + images_nested.append( + [ + img.image.url + for img in prefetched_images + if not getattr(img, "removed", False) + and getattr(img, "image", None) + ] + ) case.viva_details_json = json.dumps( { @@ -5964,8 +6532,7 @@ def collection_detail(request, pk): # cases = collection.cases.all().order_by("casedetail__sort_order") casedetails = ( - CaseDetail.objects - .filter(collection=collection) + CaseDetail.objects.filter(collection=collection) .select_related("case") .annotate(series_count=Count("case__seriesdetail")) .order_by("sort_order") @@ -5994,8 +6561,14 @@ def collection_detail(request, pk): ), total_review_message_count=Count("case_review_messages", distinct=True), ) - .filter(Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0)) - .order_by("-outstanding_message_count", "-total_review_message_count", "-start_time") + .filter( + Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0) + ) + .order_by( + "-outstanding_message_count", + "-total_review_message_count", + "-start_time", + ) ) cid_message_rows = list( @@ -6012,8 +6585,14 @@ def collection_detail(request, pk): ), total_review_message_count=Count("case_review_messages", distinct=True), ) - .filter(Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0)) - .order_by("-outstanding_message_count", "-total_review_message_count", "-start_time") + .filter( + Q(outstanding_message_count__gt=0) | Q(total_review_message_count__gt=0) + ) + .order_by( + "-outstanding_message_count", + "-total_review_message_count", + "-start_time", + ) ) else: total_outstanding_messages = 0 @@ -6040,7 +6619,9 @@ def collection_history(request, exam_id: int): """An overview page that shows who has attempted / finised the collection""" collection = get_object_or_404(CaseCollection, pk=exam_id) - casedetails = list(CaseDetail.objects.filter(collection=collection).order_by("sort_order")) + casedetails = list( + CaseDetail.objects.filter(collection=collection).order_by("sort_order") + ) if _collection_case_query_messaging_enabled(collection): userexams = list( @@ -6076,8 +6657,16 @@ def collection_history(request, exam_id: int): .order_by("-outstanding_feedback_count", "completed", "-start_time") ) else: - userexams = list(collection.get_user_exams().select_related("user_user").order_by("completed", "-start_time")) - cidexams = list(collection.get_cid_exams().select_related("cid_user").order_by("completed", "-start_time")) + userexams = list( + collection.get_user_exams() + .select_related("user_user") + .order_by("completed", "-start_time") + ) + cidexams = list( + collection.get_cid_exams() + .select_related("cid_user") + .order_by("completed", "-start_time") + ) for userexam in userexams: userexam.outstanding_feedback_count = 0 userexam.total_review_message_count = 0 @@ -6085,9 +6674,9 @@ def collection_history(request, exam_id: int): cidexam.outstanding_feedback_count = 0 cidexam.total_review_message_count = 0 - total_outstanding_messages = sum(item.outstanding_feedback_count for item in userexams) + sum( - item.outstanding_feedback_count for item in cidexams - ) + total_outstanding_messages = sum( + item.outstanding_feedback_count for item in userexams + ) + sum(item.outstanding_feedback_count for item in cidexams) completed_attempt_count = sum(1 for item in userexams if item.completed) + sum( 1 for item in cidexams if item.completed ) @@ -6135,7 +6724,9 @@ def _get_case_review_stats_for_exam( return stats_by_case, 0, 0 message_rows = ( - CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case_id__in=case_ids) + CaseReviewMessage.objects.filter( + cid_user_exam=cid_user_exam, case_id__in=case_ids + ) .values("case_id") .annotate( message_count=Count("id"), @@ -6195,10 +6786,12 @@ def _build_collection_feedback_rows( casedetails: list[CaseDetail] | QuerySet[CaseDetail], cid: int | None = None, ) -> tuple[list[dict[str, Any]], int, int]: - 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), + 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 = [] @@ -6234,7 +6827,9 @@ def collection_history_user(request, exam_id: int, user_pk: int): collection=collection, ).order_by("sort_order") - reviews = cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails]).order_by("-review_update_date") + reviews = cid_user_exam.selfreview_set.filter( + case__in=[cd.case for cd in casedetails] + ).order_by("-review_update_date") reviews_by_case = {} for review in reviews: reviews_by_case.setdefault(review.case_id, []).append(review) @@ -6291,7 +6886,9 @@ def collection_history_ciduser(request, exam_id: int, cid: int): collection=collection, ).order_by("sort_order") - reviews = cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails]).order_by("-review_update_date") + reviews = cid_user_exam.selfreview_set.filter( + case__in=[cd.case for cd in casedetails] + ).order_by("-review_update_date") reviews_by_case = {} for review in reviews: reviews_by_case.setdefault(review.case_id, []).append(review) @@ -6355,7 +6952,9 @@ def collection_user_messages(request, exam_id: int, user_pk: int): cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user) casedetails = list( - CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order") + CaseDetail.objects.filter(collection=collection) + .select_related("case") + .order_by("sort_order") ) feedback_rows, _, _ = _build_collection_feedback_rows( collection=collection, @@ -6363,9 +6962,18 @@ def collection_user_messages(request, exam_id: int, user_pk: int): casedetails=casedetails, ) - outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_any")] - history_rows = [row for row in feedback_rows if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_any")] - total_outstanding_messages = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows) + outstanding_rows = [ + row for row in feedback_rows if row["stats"].get("has_outstanding_any") + ] + history_rows = [ + row + for row in feedback_rows + if row["stats"].get("has_messages") + and not row["stats"].get("has_outstanding_any") + ] + total_outstanding_messages = sum( + row["stats"].get("outstanding_total_count", 0) for row in feedback_rows + ) show_all = request.GET.get("show") == "all" return render( @@ -6395,7 +7003,9 @@ def collection_cid_messages(request, exam_id: int, cid: int): cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid) casedetails = list( - CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order") + CaseDetail.objects.filter(collection=collection) + .select_related("case") + .order_by("sort_order") ) feedback_rows, _, _ = _build_collection_feedback_rows( collection=collection, @@ -6404,9 +7014,18 @@ def collection_cid_messages(request, exam_id: int, cid: int): cid=cid, ) - outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_any")] - history_rows = [row for row in feedback_rows if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_any")] - total_outstanding_messages = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows) + outstanding_rows = [ + row for row in feedback_rows if row["stats"].get("has_outstanding_any") + ] + history_rows = [ + row + for row in feedback_rows + if row["stats"].get("has_messages") + and not row["stats"].get("has_outstanding_any") + ] + total_outstanding_messages = sum( + row["stats"].get("outstanding_total_count", 0) for row in feedback_rows + ) show_all = request.GET.get("show") == "all" return render( @@ -6438,18 +7057,22 @@ def user_messages_inbox(request): user = request.user collection_content_type = ContentType.objects.get_for_model(CaseCollection) - is_global_moderator = user.is_superuser or user.groups.filter(name="atlas_editor").exists() - - base_attempts = CidUserExam.objects.filter(content_type=collection_content_type).select_related( - "user_user", "cid_user" + is_global_moderator = ( + user.is_superuser or user.groups.filter(name="atlas_editor").exists() ) + + base_attempts = CidUserExam.objects.filter( + content_type=collection_content_type + ).select_related("user_user", "cid_user") learner_attempts = list(base_attempts.filter(user_user=user)) moderator_attempts = [] if is_global_moderator: moderator_attempts = list(base_attempts) else: - authored_ids = list(CaseCollection.objects.filter(author=user).values_list("pk", flat=True)) + authored_ids = list( + CaseCollection.objects.filter(author=user).values_list("pk", flat=True) + ) if authored_ids: moderator_attempts = list(base_attempts.filter(object_id__in=authored_ids)) @@ -6479,10 +7102,13 @@ def user_messages_inbox(request): enabled_collection_ids = { collection_id for collection_id, collection in collections_by_id.items() - if collection is not None and _collection_case_query_messaging_enabled(collection) + if collection is not None + and _collection_case_query_messaging_enabled(collection) } - attempts = [attempt for attempt in attempts if attempt.object_id in enabled_collection_ids] + attempts = [ + attempt for attempt in attempts if attempt.object_id in enabled_collection_ids + ] if not attempts: return render( request, @@ -6542,7 +7168,8 @@ def user_messages_inbox(request): "message_count": row["message_count"], "outstanding_total_count": outstanding_total_count, "outstanding_query_count": outstanding_query_count, - "outstanding_feedback_count": outstanding_total_count - outstanding_query_count, + "outstanding_feedback_count": outstanding_total_count + - outstanding_query_count, "has_messages": row["message_count"] > 0, "has_outstanding_any": outstanding_total_count > 0, } @@ -6552,7 +7179,9 @@ def user_messages_inbox(request): attempt = item["attempt"] viewer_role = item["viewer_role"] collection = collections_by_id.get(attempt.object_id) - if collection is None or not _collection_case_query_messaging_enabled(collection): + if collection is None or not _collection_case_query_messaging_enabled( + collection + ): continue casedetails = casedetails_by_collection.get(collection.pk, []) @@ -6604,11 +7233,14 @@ def user_messages_inbox(request): } ) - outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_any")] + outstanding_rows = [ + row for row in feedback_rows if row["stats"].get("has_outstanding_any") + ] history_rows = [ row for row in feedback_rows - if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_any") + if row["stats"].get("has_messages") + and not row["stats"].get("has_outstanding_any") ] if not outstanding_rows and not history_rows: @@ -6626,9 +7258,15 @@ def user_messages_inbox(request): kwargs={"exam_id": collection.pk, "cid": attempt.cid_user.cid}, ) - outstanding_count = sum(row["stats"].get("outstanding_total_count", 0) for row in feedback_rows) - needs_your_ack_count = sum(row["stats"].get("needs_your_ack_count", 0) for row in feedback_rows) - waiting_on_other_count = sum(row["stats"].get("waiting_on_other_count", 0) for row in feedback_rows) + outstanding_count = sum( + row["stats"].get("outstanding_total_count", 0) for row in feedback_rows + ) + needs_your_ack_count = sum( + row["stats"].get("needs_your_ack_count", 0) for row in feedback_rows + ) + waiting_on_other_count = sum( + row["stats"].get("waiting_on_other_count", 0) for row in feedback_rows + ) inbox_rows.append( { "collection": collection, @@ -6709,7 +7347,10 @@ def _get_case_review_thread_context( try: collection.check_user_can_take(cid, passcode, request.user) if cid_user_exam.user_user_id: - can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id + can_query = ( + request.user.is_authenticated + and cid_user_exam.user_user_id == request.user.id + ) elif cid_user_exam.cid_user_id and cid is not None: can_query = str(cid_user_exam.cid_user.cid) == str(cid) can_acknowledge_feedback = can_query @@ -6742,7 +7383,9 @@ def _get_case_review_thread_context( ) unacknowledged_count = sum( - 1 for message in messages if message.requires_acknowledgement and not message.is_acknowledged + 1 + for message in messages + if message.requires_acknowledgement and not message.is_acknowledged ) if can_feedback: @@ -6790,7 +7433,9 @@ def _get_case_review_thread_context( @require_http_methods(["GET", "POST"]) -def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, case_id: int): +def collection_case_review_thread( + request, exam_id: int, cid_user_exam_id: int, case_id: int +): """Render and mutate the per-case conversation thread for one learner attempt. The thread supports supervisor feedback/replies, learner queries, and learner @@ -6824,7 +7469,10 @@ def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, try: sup = cid_user_exam.user_user.userprofile.supervisor if sup and sup.user_id == request.user.id: - if cid_user_exam.share_with_supervisor or collection.results_supervisor_visible: + if ( + cid_user_exam.share_with_supervisor + or collection.results_supervisor_visible + ): is_supervisor = True except Exception: pass @@ -6835,7 +7483,10 @@ def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, try: collection.check_user_can_take(cid, passcode, request.user) if cid_user_exam.user_user_id: - can_query = request.user.is_authenticated and cid_user_exam.user_user_id == request.user.id + can_query = ( + request.user.is_authenticated + and cid_user_exam.user_user_id == request.user.id + ) elif cid_user_exam.cid_user_id and cid is not None: can_query = str(cid_user_exam.cid_user.cid) == str(cid) except Exception: @@ -6854,7 +7505,9 @@ def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, allow_ack=True, message_placeholder="Give case-specific feedback or reply to a learner query...", ) - message_type = request.POST.get("message_type", CaseReviewMessage.MessageType.FEEDBACK) + message_type = request.POST.get( + "message_type", CaseReviewMessage.MessageType.FEEDBACK + ) if message_type not in { CaseReviewMessage.MessageType.FEEDBACK, CaseReviewMessage.MessageType.REPLY, @@ -6877,7 +7530,9 @@ def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, message.author_label = "" else: message.author = None - message.author_label = f"CID {cid}" if cid is not None else "Learner" + message.author_label = ( + f"CID {cid}" if cid is not None else "Learner" + ) message.message_type = message_type if message_type == CaseReviewMessage.MessageType.QUERY: # Learner queries must be acknowledged by a supervisor/author. @@ -6897,9 +7552,15 @@ def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, case=case, ) - if can_feedback and message.message_type != CaseReviewMessage.MessageType.QUERY: + if ( + can_feedback + and message.message_type != CaseReviewMessage.MessageType.QUERY + ): raise PermissionDenied - if can_query and message.message_type == CaseReviewMessage.MessageType.QUERY: + if ( + can_query + and message.message_type == CaseReviewMessage.MessageType.QUERY + ): raise PermissionDenied if not (can_feedback or can_query): raise PermissionDenied @@ -6916,8 +7577,6 @@ def collection_case_review_thread(request, exam_id: int, cid_user_exam_id: int, passcode=passcode, ) return render(request, "atlas/partials/_case_review_thread.html", context) - - def collection_take_start(request, pk, cid=None, passcode=None): @@ -6940,11 +7599,11 @@ def collection_take_start(request, pk, cid=None, passcode=None): return HttpResponse(f"

    {str(e)}

    ") except PrerequisiteRequired as e: # Show a friendly page linking to the required collection rather than 404 - prereq = getattr(e, 'prereq', None) + prereq = getattr(e, "prereq", None) return render( request, - 'atlas/prerequisite_required.html', - {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode}, + "atlas/prerequisite_required.html", + {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode}, ) # Check if pre-survey is required and not yet taken @@ -6952,9 +7611,7 @@ def collection_take_start(request, pk, cid=None, passcode=None): if response: return response - - #valid_user = collection.check_logged_in_user(request) - + # valid_user = collection.check_logged_in_user(request) cid_exam = None if collection.collection_type == "REV" or valid_user: @@ -7004,31 +7661,43 @@ def _case_number_for_collection_and_caseid(collection_pk, case_id): @login_required def redirect_collection_case_details_by_id(request, exam_id, case_id): case_number = _case_number_for_collection_and_caseid(exam_id, case_id) - return redirect("atlas:collection_case_details", exam_id=exam_id, case_number=case_number) + return redirect( + "atlas:collection_case_details", exam_id=exam_id, case_number=case_number + ) @login_required def redirect_collection_case_questions_by_id(request, exam_id, case_id): case_number = _case_number_for_collection_and_caseid(exam_id, case_id) - return redirect("atlas:collection_case_questions", exam_id=exam_id, case_number=case_number) + return redirect( + "atlas:collection_case_questions", exam_id=exam_id, case_number=case_number + ) @login_required def redirect_collection_case_priors_by_id(request, exam_id, case_id): case_number = _case_number_for_collection_and_caseid(exam_id, case_id) - return redirect("atlas:collection_case_priors", exam_id=exam_id, case_number=case_number) + return redirect( + "atlas:collection_case_priors", exam_id=exam_id, case_number=case_number + ) @login_required def redirect_collection_case_dicom_json_by_id(request, exam_id, case_id): case_number = _case_number_for_collection_and_caseid(exam_id, case_id) - return redirect("atlas:collection_case_dicom_json", exam_id=exam_id, case_number=case_number) + return redirect( + "atlas:collection_case_dicom_json", exam_id=exam_id, case_number=case_number + ) @login_required def redirect_collection_case_dicom_json_review_by_id(request, exam_id, case_id): case_number = _case_number_for_collection_and_caseid(exam_id, case_id) - return redirect("atlas:collection_case_dicom_json_review", exam_id=exam_id, case_number=case_number) + return redirect( + "atlas:collection_case_dicom_json_review", + exam_id=exam_id, + case_number=case_number, + ) @login_required @@ -7040,26 +7709,39 @@ def redirect_collection_case_view_by_id(request, pk, case_id): @login_required def redirect_collection_case_displaysetup_by_id(request, collection_id, case_id): case_number = _case_number_for_collection_and_caseid(collection_id, case_id) - return redirect("atlas:collection_case_displaysetup", collection_id=collection_id, case_number=case_number) + return redirect( + "atlas:collection_case_displaysetup", + collection_id=collection_id, + case_number=case_number, + ) @login_required def redirect_collection_case_view_take_by_id(request, pk, case_id, cid, passcode): case_number = _case_number_for_collection_and_caseid(pk, case_id) - return redirect("atlas:collection_case_view_take", pk=pk, case_number=case_number, cid=cid, passcode=passcode) + return redirect( + "atlas:collection_case_view_take", + pk=pk, + case_number=case_number, + cid=cid, + passcode=passcode, + ) @login_required def redirect_collection_case_view_take_user_by_id(request, pk, case_id): case_number = _case_number_for_collection_and_caseid(pk, case_id) - return redirect("atlas:collection_case_view_take_user", pk=pk, case_number=case_number) + return redirect( + "atlas:collection_case_view_take_user", pk=pk, case_number=case_number + ) @login_required def redirect_collection_case_view_take_user_answers_by_id(request, pk, case_id): case_number = _case_number_for_collection_and_caseid(pk, case_id) - return redirect("atlas:collection_case_view_take_user_answers", pk=pk, case_number=case_number) - + return redirect( + "atlas:collection_case_view_take_user_answers", pk=pk, case_number=case_number + ) @user_is_collection_author_or_atlas_editor @@ -7071,7 +7753,9 @@ def collection_case_priors(request, exam_id, case_number): except Exception: raise Http404("Case not found in collection") - def _render_prior_card_response(prior_case, added, relation, visibility, relation_type, error=None): + def _render_prior_card_response( + prior_case, added, relation, visibility, relation_type, error=None + ): html = render_to_string( "atlas/partials/_prior_card.html", { @@ -7125,7 +7809,9 @@ def collection_case_priors(request, exam_id, case_number): relation_type=rel_type, ) elif "prior_case_id" in request.POST: - prior_case = Case.objects.filter(pk=request.POST.get("prior_case_id")).first() + prior_case = Case.objects.filter( + pk=request.POST.get("prior_case_id") + ).first() if prior_case is None: return HttpResponse("Prior case not found", status=404) @@ -7151,8 +7837,12 @@ def collection_case_priors(request, exam_id, case_number): current_date = casedetail.case.study_date prior_date = prior_case.study_date if current_date and prior_date: - relative = _format_relative_date_offset((prior_date - current_date).days) - relation = f"Study date {relative['text']} relative to the index case" + relative = _format_relative_date_offset( + (prior_date - current_date).days + ) + relation = ( + f"Study date {relative['text']} relative to the index case" + ) else: return _render_prior_card_response( prior_case, @@ -7204,7 +7894,11 @@ def collection_case_priors(request, exam_id, case_number): added_relation_map = {} for prior in added_priors: - added_relation_map[prior.prior_case] = (prior.relation_text, prior.prior_visibility, prior.relation_type) + added_relation_map[prior.prior_case] = ( + prior.relation_text, + prior.prior_visibility, + prior.relation_type, + ) case = casedetail.case @@ -7213,7 +7907,15 @@ def collection_case_priors(request, exam_id, case_number): available_priors = [] for c in available_prior_cases: if c in added_relation_map: - available_priors.append((c, True, added_relation_map[c][0], added_relation_map[c][1], added_relation_map[c][2])) + available_priors.append( + ( + c, + True, + added_relation_map[c][0], + added_relation_map[c][1], + added_relation_map[c][2], + ) + ) else: available_priors.append((c, False, "", "AL", CasePrior.RelationType.PRIOR)) @@ -7222,9 +7924,19 @@ def collection_case_priors(request, exam_id, case_number): available_futures = [] for c in available_future_cases: if c in added_relation_map: - available_futures.append((c, True, added_relation_map[c][0], added_relation_map[c][1], added_relation_map[c][2])) + available_futures.append( + ( + c, + True, + added_relation_map[c][0], + added_relation_map[c][1], + added_relation_map[c][2], + ) + ) else: - available_futures.append((c, False, "", "AL", CasePrior.RelationType.FUTURE)) + available_futures.append( + (c, False, "", "AL", CasePrior.RelationType.FUTURE) + ) form = PriorCaseForm() @@ -7236,7 +7948,10 @@ def collection_case_priors(request, exam_id, case_number): next = collection.get_next_case(casedetail.case) # Determine whether the current user can edit this collection/case so # included fragments which rely on `can_edit` have the value available. - can_edit = request.user.is_superuser or collection.author.filter(pk=request.user.pk).exists() + can_edit = ( + request.user.is_superuser + or collection.author.filter(pk=request.user.pk).exists() + ) return render( request, @@ -7244,7 +7959,7 @@ def collection_case_priors(request, exam_id, case_number): { "casedetail": casedetail, "form": form, - #"model_form": model_form, + # "model_form": model_form, # "example_form": example_form, "collection": collection, "case": casedetail.case, @@ -7379,6 +8094,7 @@ def collection_case_questions(request, exam_id, case_number): }, ) + @user_is_collection_author_or_atlas_editor def collection_case_details(request, exam_id, case_number): # Accept either case_number (index into collection) or case_id (case PK). @@ -7555,7 +8271,9 @@ def collection_mark_question(request, pk, case_number): form = CidReportAnswerMarkForm(instance=answer, prefix=answer.pk) answer_forms.append((answer, form)) - series_list = casedetail.get_visible_case_series(review_mode=True) if casedetail else [] + series_list = ( + casedetail.get_visible_case_series(review_mode=True) if casedetail else [] + ) return render( request, @@ -7617,24 +8335,39 @@ def collection_feedback_overview( try: collection.check_user_can_take(cid, passcode, request.user) except PrerequisiteRequired as e: - prereq = getattr(e, 'prereq', None) - return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode}) + prereq = getattr(e, "prereq", None) + return render( + request, + "atlas/prerequisite_required.html", + {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode}, + ) cid_user_exam = collection.get_or_create_cid_user_exam( cid=cid, user_user=request.user ) casedetails = list( - CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order") + CaseDetail.objects.filter(collection=collection) + .select_related("case") + .order_by("sort_order") ) - feedback_rows, outstanding_case_count, total_outstanding_feedback = _build_collection_feedback_rows( - collection=collection, - cid_user_exam=cid_user_exam, - casedetails=casedetails, - cid=cid, + feedback_rows, outstanding_case_count, total_outstanding_feedback = ( + _build_collection_feedback_rows( + collection=collection, + cid_user_exam=cid_user_exam, + casedetails=casedetails, + cid=cid, + ) ) - outstanding_rows = [row for row in feedback_rows if row["stats"].get("has_outstanding_feedback")] - history_rows = [row for row in feedback_rows if row["stats"].get("has_messages") and not row["stats"].get("has_outstanding_feedback")] + outstanding_rows = [ + row for row in feedback_rows if row["stats"].get("has_outstanding_feedback") + ] + history_rows = [ + row + for row in feedback_rows + if row["stats"].get("has_messages") + and not row["stats"].get("has_outstanding_feedback") + ] return render( request, @@ -7674,8 +8407,12 @@ def collection_take_overview( try: collection.check_user_can_take(cid, passcode, request.user) except PrerequisiteRequired as e: - prereq = getattr(e, 'prereq', None) - return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode}) + prereq = getattr(e, "prereq", None) + return render( + request, + "atlas/prerequisite_required.html", + {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode}, + ) cid_user_exam = collection.get_or_create_cid_user_exam( cid=cid, user_user=request.user @@ -7699,7 +8436,9 @@ def collection_take_overview( cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related() casedetails = list( - CaseDetail.objects.filter(case__in=cases, collection=collection).select_related("case") + CaseDetail.objects.filter(case__in=cases, collection=collection).select_related( + "case" + ) ) if cid is not None: @@ -7713,10 +8452,12 @@ def collection_take_overview( for ans in answers: answer_question_map[ans.question] = ans - case_review_stats, 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), + case_review_stats, 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), + ) ) question_answer_tuples = [] @@ -7729,32 +8470,40 @@ def collection_take_overview( if cd in answer_question_map and ( answer_question_map[cd].answer or answer_question_map[cd].json_answer ): - question_answer_tuples.append((cd, answer_question_map[cd], self_review, review_stats, timing_state)) + question_answer_tuples.append( + (cd, answer_question_map[cd], self_review, review_stats, timing_state) + ) answer_count += 1 else: - question_answer_tuples.append((cd, None, self_review, review_stats, timing_state)) + question_answer_tuples.append( + (cd, None, self_review, review_stats, timing_state) + ) post_survey_url = None if collection.post_survey: user_param = request.user if request.user.is_authenticated else None cid_param = int(cid) if cid and str(cid).isdigit() else None - + from django.contrib.contenttypes.models import ContentType from survey.models import SurveyResponse + ct = ContentType.objects.get_for_model(collection) - + has_responded = SurveyResponse.objects.filter( survey=collection.post_survey, user=user_param, cid=cid_param, content_type=ct, object_id=collection.pk, - pre_or_post=SurveyResponse.SurveyContext.POST + pre_or_post=SurveyResponse.SurveyContext.POST, ).exists() - + if not has_responded: from django.urls import reverse - survey_url = reverse("survey:survey_take", kwargs={"pk": collection.post_survey.pk}) + + survey_url = reverse( + "survey:survey_take", kwargs={"pk": collection.post_survey.pk} + ) is_mandatory = getattr(collection, "post_survey_mandatory", False) if is_mandatory: params = f"?pre_or_post=POST&content_type_id={ct.id}&object_id={collection.pk}&next={request.get_full_path()}&mandatory=true" @@ -7892,7 +8641,9 @@ def collection_exam_sharing_panel(request, pk): if not request.htmx: raise PermissionDenied() - is_owner = bool(cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk) + is_owner = bool( + cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk + ) if request.method == "POST": if not is_owner: @@ -7901,7 +8652,9 @@ def collection_exam_sharing_panel(request, pk): action = request.POST.get("action") if action == "toggle_supervisor": - cid_user_exam.share_with_supervisor = not cid_user_exam.share_with_supervisor + cid_user_exam.share_with_supervisor = ( + not cid_user_exam.share_with_supervisor + ) cid_user_exam.save(update_fields=["share_with_supervisor"]) elif action == "add_user": @@ -7914,7 +8667,9 @@ def collection_exam_sharing_panel(request, pk): elif action == "remove_user": try: - user_to_remove = User.objects.get(pk=int(request.POST.get("user_id", ""))) + user_to_remove = User.objects.get( + pk=int(request.POST.get("user_id", "")) + ) cid_user_exam.shared_with_users.remove(user_to_remove) except (User.DoesNotExist, ValueError, TypeError): pass @@ -7943,13 +8698,17 @@ def collection_exam_user_search(request, pk): if not request.htmx: raise PermissionDenied() - if not (cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk): + if not ( + cid_user_exam.user_user_id and cid_user_exam.user_user_id == request.user.pk + ): raise PermissionDenied() q = (request.GET.get("q") or "").strip() users = [] if len(q) >= 2: - already_shared = set(cid_user_exam.shared_with_users.values_list("pk", flat=True)) + already_shared = set( + cid_user_exam.shared_with_users.values_list("pk", flat=True) + ) users = list( User.objects.filter( Q(first_name__icontains=q) @@ -7979,7 +8738,6 @@ def collection_shared_with_me(request): return redirect(reverse("atlas:collection_supervision")) - @login_required def collection_shared_attempt_overview(request, cid_user_exam_pk): """Read-only overview of a learner's attempt, visible to authorised third parties. @@ -8000,7 +8758,9 @@ def collection_shared_attempt_overview(request, cid_user_exam_pk): raise Http404() casedetails = list( - CaseDetail.objects.filter(collection=collection).select_related("case").order_by("sort_order") + CaseDetail.objects.filter(collection=collection) + .select_related("case") + .order_by("sort_order") ) reviews = cid_user_exam.selfreview_set.filter( @@ -8104,22 +8864,28 @@ def collection_supervision(request): supervisor_qs = CidUserExam.objects.none() supervisor_exam_qs = CidUserExam.objects.none() if is_supervisor: - supervisor_qs = CidUserExam.objects.filter( - content_type=collection_ct, - share_with_supervisor=True, - ).filter( - Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users) - ).exclude(user_user=request.user) + supervisor_qs = ( + CidUserExam.objects.filter( + content_type=collection_ct, + share_with_supervisor=True, + ) + .filter(Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users)) + .exclude(user_user=request.user) + ) visible_collection_ids = list( - CaseCollection.objects.filter(results_supervisor_visible=True).values_list("pk", flat=True) + CaseCollection.objects.filter(results_supervisor_visible=True).values_list( + "pk", flat=True + ) + ) + supervisor_exam_qs = ( + CidUserExam.objects.filter( + content_type=collection_ct, + object_id__in=visible_collection_ids, + ) + .filter(Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users)) + .exclude(user_user=request.user) ) - supervisor_exam_qs = CidUserExam.objects.filter( - content_type=collection_ct, - object_id__in=visible_collection_ids, - ).filter( - Q(user_user__in=trainee_users) | Q(cid_user__in=trainee_cid_users) - ).exclude(user_user=request.user) if supervisor_only: all_attempts = (supervisor_qs | supervisor_exam_qs).distinct() @@ -8176,11 +8942,15 @@ def collection_supervision(request): key = (key_id, key_label) else: if attempt.user_user_id: - key_label = attempt.user_user.get_full_name() or attempt.user_user.username + key_label = ( + attempt.user_user.get_full_name() or attempt.user_user.username + ) key_id = attempt.user_user_id elif attempt.cid_user_id: name = attempt.cid_user.name or "" - key_label = f"CID {attempt.cid_user.cid}" + (f" – {name}" if name else "") + key_label = f"CID {attempt.cid_user.cid}" + ( + f" – {name}" if name else "" + ) key_id = f"cid_{attempt.cid_user_id}" else: key_label = "Unknown learner" @@ -8213,7 +8983,9 @@ def collection_case_view_take_user_answers(request, pk: int, case_number: int): @login_required @user_is_collection_author_or_atlas_editor -def collection_case_view_take_review_user(request, pk: int, case_number: int, user_pk: int): +def collection_case_view_take_review_user( + request, pk: int, case_number: int, user_pk: int +): """Read-only case review screen for supervisors and collection authors/editors. Renders the same case-taking template as the candidate view but in a purely @@ -8245,6 +9017,7 @@ def collection_case_view_take_review_user(request, pk: int, case_number: int, us # using get_or_create, but we deliberately avoid creating one here). from generic.models import CidUserExam from django.contrib.contenttypes.models import ContentType + ct = ContentType.objects.get_for_model(CaseCollection) cid_user_exam = CidUserExam.objects.filter( content_type=ct, @@ -8260,9 +9033,11 @@ def collection_case_view_take_review_user(request, pk: int, case_number: int, us if answer: if collection.collection_type == "REP": from atlas.forms import UserReportAnswerForm + form = UserReportAnswerForm(instance=answer, casedetail=casedetail) else: from atlas.forms import UserQuestionAnswerForm + form = UserQuestionAnswerForm(instance=answer, casedetail=casedetail) # Determine visibility / review state — treat the exam as completed for a @@ -8341,7 +9116,9 @@ def collection_case_view_take_review_user(request, pk: int, case_number: int, us "case_review_stats": case_review_stats, "question_completed": question_completed, "self_review": self_review, - "answer_started_at_iso": answer.started_at.isoformat() if answer and getattr(answer, "started_at", None) else None, + "answer_started_at_iso": answer.started_at.isoformat() + if answer and getattr(answer, "started_at", None) + else None, "effective_question_time_limit": None, "effective_answer_entry_grace_period": None, "case_view_locked": False, @@ -8363,8 +9140,12 @@ def collection_case_view_take_answers( try: collection.check_user_can_take(cid, passcode, request.user) except PrerequisiteRequired as e: - prereq = getattr(e, 'prereq', None) - return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode}) + prereq = getattr(e, "prereq", None) + return render( + request, + "atlas/prerequisite_required.html", + {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode}, + ) case, case_count = collection.get_case_by_index(case_number, case_count=True) @@ -8388,7 +9169,9 @@ def collection_case_view_take_answers( def collection_case_view_take_user(request, pk, case_number=None, case_id=None): # Accept either case_number (index) or case_id (case PK) and forward both to # the handler. This allows URLs that use either name to keep working. - return collection_case_view_take(request, pk, case_number=case_number, cid=None, passcode=None, case_id=case_id) + return collection_case_view_take( + request, pk, case_number=case_number, cid=None, passcode=None, case_id=case_id + ) def collection_case_view_take( @@ -8411,6 +9194,7 @@ def collection_case_view_take( # Check if pre-survey is required and not yet taken from survey.views import check_survey_interception + response = check_survey_interception(request, collection, cid, passcode) if response: return response @@ -8436,8 +9220,12 @@ def collection_case_view_take( try: collection.check_user_can_take(cid, passcode, request.user) except PrerequisiteRequired as e: - prereq = getattr(e, 'prereq', None) - return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode}) + prereq = getattr(e, "prereq", None) + return render( + request, + "atlas/prerequisite_required.html", + {"message": str(e), "prereq": prereq, "cid": cid, "passcode": passcode}, + ) case, case_count = collection.get_case_by_index(case_number, case_count=True) @@ -8471,13 +9259,13 @@ def collection_case_view_take( ReportAnswerForm = UserQuestionAnswerForm # If we have an answer object but it doesn't have a started_at, set it now. - if answer and getattr(answer, 'started_at', None) is None: + if answer and getattr(answer, "started_at", None) is None: try: answer.started_at = timezone.now() answer.save() except Exception: # Non-fatal: if saving fails, continue without blocking the user - logger.exception("Failed to set started_at on answer", exc_info=True) + logger.exception("Failed to set started_at on answer", exc_info=True) # If no answer exists yet, create a placeholder so we have a started_at timestamp if not answer and collection.collection_type in ("REP", "QUE"): @@ -8491,11 +9279,18 @@ def collection_case_view_take( except Exception: # If creation fails, leave answer as None; the normal flow will handle form creation answer = None - logger.exception("Failed to create initial answer object", exc_info=True) + logger.exception( + "Failed to create initial answer object", exc_info=True + ) timing_state = _build_case_timing_state(collection, casedetail, answer) - if request.method != "POST" and timing_state["answer_entry_closed"] and answer and not answer.completed: + if ( + request.method != "POST" + and timing_state["answer_entry_closed"] + and answer + and not answer.completed + ): answer.completed = True if not answer.submitted_at: answer.submitted_at = timezone.now() @@ -8511,7 +9306,7 @@ def collection_case_view_take( else: form = ReportAnswerForm(request.POST, casedetail=casedetail) - timed_out_submission = request.POST.get('timed_out') == '1' + timed_out_submission = request.POST.get("timed_out") == "1" if not cid_user_exam.completed and not ( answer is not None and answer.completed @@ -8524,16 +9319,19 @@ def collection_case_view_take( answer.save() is_ajax = ( - request.headers.get('HX-Request', '').lower() == 'true' - or request.headers.get('x-requested-with') == 'XMLHttpRequest' - or getattr(request, 'htmx', False) + request.headers.get("HX-Request", "").lower() == "true" + or request.headers.get("x-requested-with") + == "XMLHttpRequest" + or getattr(request, "htmx", False) ) if is_ajax: return JsonResponse( { - 'status': 'closed', - 'locked': True, - 'submitted_at': answer.submitted_at.isoformat() if answer and getattr(answer, 'submitted_at', None) else None, + "status": "closed", + "locked": True, + "submitted_at": answer.submitted_at.isoformat() + if answer and getattr(answer, "submitted_at", None) + else None, } ) @@ -8551,10 +9349,10 @@ def collection_case_view_take( answer.completed = True # If this was a timed-out submission, mark as completed - if request.POST.get('timed_out') == '1': + if request.POST.get("timed_out") == "1": answer.completed = True ## Also mark the overall exam as completed for this user/CID - #try: + # try: # cid_user_exam.completed = True # cid_user_exam.save() # # Record an exam status entry @@ -8563,7 +9361,7 @@ def collection_case_view_take( # status="submitted", # extra="autosubmit", # ) - #except Exception: + # except Exception: # logger.exception("Failed to mark cid_user_exam completed on autosubmit") answer.save() @@ -8575,16 +9373,19 @@ def collection_case_view_take( # can remain on the same page and update the UI without redirect. # Detect HTMX/XHR requests robustly. HTMX sets the HX-Request header. is_ajax = ( - request.headers.get('HX-Request', '').lower() == 'true' - or request.headers.get('x-requested-with') == 'XMLHttpRequest' - or getattr(request, 'htmx', False) + request.headers.get("HX-Request", "").lower() == "true" + or request.headers.get("x-requested-with") + == "XMLHttpRequest" + or getattr(request, "htmx", False) ) if is_ajax: return JsonResponse( { - 'status': 'ok', - 'locked': answer.completed, - 'submitted_at': answer.submitted_at.isoformat() if getattr(answer, 'submitted_at', None) else None, + "status": "ok", + "locked": answer.completed, + "submitted_at": answer.submitted_at.isoformat() + if getattr(answer, "submitted_at", None) + else None, } ) @@ -8678,12 +9479,8 @@ def collection_case_view_take( messaging_enabled=_collection_case_query_messaging_enabled(collection), ) - case_view_locked = ( - not question_completed and timing_state["case_view_locked"] - ) - answer_entry_closed = ( - not question_completed and timing_state["answer_entry_closed"] - ) + case_view_locked = not question_completed and timing_state["case_view_locked"] + answer_entry_closed = not question_completed and timing_state["answer_entry_closed"] return render( request, @@ -8716,9 +9513,13 @@ def collection_case_view_take( "case_review_stats": case_review_stats.get(casedetail.case_id, {}), "question_completed": question_completed, "self_review": self_review, - "answer_started_at_iso": answer.started_at.isoformat() if answer and getattr(answer, 'started_at', None) else None, + "answer_started_at_iso": answer.started_at.isoformat() + if answer and getattr(answer, "started_at", None) + else None, "effective_question_time_limit": timing_state["effective_time_limit"], - "effective_answer_entry_grace_period": timing_state["effective_answer_grace_period"], + "effective_answer_entry_grace_period": timing_state[ + "effective_answer_grace_period" + ], "case_view_locked": case_view_locked, "answer_entry_closed": answer_entry_closed, "start_screen_resources": collection.start_screen_resources.all(), @@ -8768,7 +9569,9 @@ def collection_case_view(request, pk, case_number=None, case_id=None): series_list = case.series.all().prefetch_related("images", "examination", "plane") ordered_series = list(getattr(case, "ordered_series", case.get_ordered_series())) for series in ordered_series: - prefetched_images = getattr(series, "_prefetched_objects_cache", {}).get("images") + prefetched_images = getattr(series, "_prefetched_objects_cache", {}).get( + "images" + ) if prefetched_images is not None: series.visible_image_count = len(prefetched_images) else: @@ -8828,6 +9631,7 @@ def use_dates_as_descriptions(request, pk): return HttpResponse("Done") + def collection_case_dicom_json_review(request, exam_id, case_number): return collection_case_dicom_json(request, exam_id, case_number, review=True) @@ -8910,7 +9714,9 @@ def resource_search_widget(request): qs = Resource.objects.all() if q: - qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q) | Q(url__icontains=q)) + qs = qs.filter( + Q(name__icontains=q) | Q(description__icontains=q) | Q(url__icontains=q) + ) if site: try: qs = qs.filter(sites__id=int(site)) @@ -8925,14 +9731,21 @@ def resource_search_widget(request): qs = qs.filter(open_access=(open_access == "1")) # Restrict to what the user may see: open_access or authored (unless editor) - if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()): + if not ( + request.user.is_superuser + or request.user.groups.filter(name="atlas_editor").exists() + ): qs = qs.filter(Q(open_access=True) | Q(author__id=request.user.id)).distinct() results = qs.order_by("name")[:50] field = request.GET.get("field", "resource_search") case_id = request.GET.get("case_id") - return render(request, "atlas/partials/resource_search_widget_results.html", {"results": results, "field": field, "case_id": case_id}) + return render( + request, + "atlas/partials/resource_search_widget_results.html", + {"results": results, "field": field, "case_id": case_id}, + ) @login_required @@ -8942,11 +9755,15 @@ def resource_search_panel(request): Expects optional GET param `case_id` to prefill the hidden field. """ case_id = request.GET.get("case_id") - return render(request, "atlas/partials/resource_search_panel.html", { - "case": (Case.objects.filter(pk=case_id).first() if case_id else None), - "site_queryset": Site.objects.all(), - "subspecialty_queryset": Subspecialty.objects.all(), - }) + return render( + request, + "atlas/partials/resource_search_panel.html", + { + "case": (Case.objects.filter(pk=case_id).first() if case_id else None), + "site_queryset": Site.objects.all(), + "subspecialty_queryset": Subspecialty.objects.all(), + }, + ) @login_required @@ -8972,7 +9789,9 @@ def case_attach_resource(request, pk): return HttpResponse(status=400) # create if not exists - cr, created = CaseResource.objects.get_or_create(case=case, resource=resource, defaults={"pre_review": pre_review}) + cr, created = CaseResource.objects.get_or_create( + case=case, resource=resource, defaults={"pre_review": pre_review} + ) if not created: # update flag cr.pre_review = pre_review @@ -8982,7 +9801,9 @@ def case_attach_resource(request, pk): case = Case.objects.get(pk=case.pk) except Exception: pass - html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request) + html = render_to_string( + "atlas/partials/case_resources_list.html", {"case": case}, request=request + ) resp = HttpResponse(html) # Notify any HTMX listeners that the case resources have changed resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}}) @@ -9006,7 +9827,9 @@ def case_detach_resource(request, pk): case = Case.objects.get(pk=case.pk) except Exception: pass - html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request) + html = render_to_string( + "atlas/partials/case_resources_list.html", {"case": case}, request=request + ) resp = HttpResponse(html) resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}}) return resp @@ -9040,7 +9863,9 @@ def case_toggle_resource_pre(request, pk): case = Case.objects.get(pk=case.pk) except Exception: pass - html = render_to_string("atlas/partials/case_resources_list.html", {"case": case}, request=request) + html = render_to_string( + "atlas/partials/case_resources_list.html", {"case": case}, request=request + ) resp = HttpResponse(html) resp["HX-Trigger"] = json.dumps({"caseResourcesChanged": {"case_id": case.pk}}) return resp @@ -9108,7 +9933,9 @@ def series_tags_partial(request, pk): if not dicoms.exists(): try: - dicoms = UncategorisedDicom.objects.filter(series_instance_uid__contains=pk) + dicoms = UncategorisedDicom.objects.filter( + series_instance_uid__contains=pk + ) except Exception: dicoms = UncategorisedDicom.objects.none() @@ -9158,7 +9985,9 @@ def series_tags_partial(request, pk): if tag_counts.get(k, 0) == num_images and len(vals) == 1: common_tags[k] = list(vals)[0] - html = render_to_string("atlas/partials/series_tags.html", {"common_tags": common_tags}, request=request) + html = render_to_string( + "atlas/partials/series_tags.html", {"common_tags": common_tags}, request=request + ) return HttpResponse(html) @@ -9172,9 +10001,7 @@ def delete_collection_cid_answers(request, exam_id, cid): case__in=cases, collection=collection ).prefetch_related("case") - cid_user_answers = CidReportAnswer.objects.filter( - question__in=casedetails, cid=cid - ) + cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails, cid=cid) temp = {} for ans in cid_user_answers: @@ -9384,6 +10211,7 @@ class SelfReviewDelete(DeleteView): The view resolves ``user_exam``, ``case`` and ``review`` from URL params and, on success, returns the learner to the case take page. """ + model = SelfReview template_name = "confirm_delete.html" # success_url = reverse_lazy("atlas:finding_view") @@ -9446,6 +10274,7 @@ class SelfReviewUpdate(UpdateView): Uses ``SelfReviewForm`` and redirects back to the case take page after save. """ + model = SelfReview form_class = SelfReviewForm @@ -9514,6 +10343,7 @@ class AddSelfReview(CreateView): The form captures free-text comments and 1-5 self-assessment scores for findings and interpretation, then returns the learner to the case take page. """ + model = SelfReview form_class = SelfReviewForm @@ -9763,7 +10593,9 @@ def collection_apply_question_template(request, pk: int): ) overwrite = request.GET.get("overwrite", "false").lower() == "true" - updated_count = collection.apply_question_template_to_cases(overwrite_existing=overwrite) + updated_count = collection.apply_question_template_to_cases( + overwrite_existing=overwrite + ) return HttpResponse( f'
    Applied question template to {updated_count} case(s).
    ' @@ -9842,7 +10674,9 @@ def collection_sync_prerequisite_users(request, pk: int): total_added_user += added_user total_added_cid_groups += added_cid_groups total_added_user_groups += added_user_groups - per_collection_added.append((col.pk, added_cid, added_user, added_cid_groups, added_user_groups)) + per_collection_added.append( + (col.pk, added_cid, added_user, added_cid_groups, added_user_groups) + ) html = render_to_string( "atlas/partials/sync_prereq_result.html", @@ -9898,6 +10732,7 @@ def collection_reset_answers_user(request, exam_id: int, user_id: int): else: raise Http404("Invalid request") + @user_is_collection_author_or_atlas_editor def collection_reset_answers_ciduser(request, exam_id: int, cid: int): if request.htmx: @@ -9979,10 +10814,10 @@ def push_case_to_cimar(request, case_id): def remove_case_from_collection(request, case_pk, collection_pk): case = get_object_or_404(Case, pk=case_pk) collection = get_object_or_404(CaseCollection, pk=collection_pk) - + # Remove the case from the collection collection.cases.remove(case) - + # If this was an HTMX request, return an empty response so the caller # can swap out (remove) the surrounding element (e.g., the
  • ). if request.htmx: @@ -9994,18 +10829,19 @@ def remove_case_from_collection(request, case_pk, collection_pk): @login_required @require_POST def remove_selected_series_from_case(request, case_pk): - ids = request.POST.getlist('series-ids') or request.POST.getlist('series_ids') + ids = request.POST.getlist("series-ids") or request.POST.getlist("series_ids") 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) - if not ids: return HttpResponse("No series selected for removal.", status=400) linked_series_ids = list( - Series.objects.filter(pk__in=ids, case=case).values_list("pk", flat=True).distinct() + Series.objects.filter(pk__in=ids, case=case) + .values_list("pk", flat=True) + .distinct() ) if not linked_series_ids: return HttpResponse("No valid series selected for removal.", status=400) @@ -10023,13 +10859,17 @@ def move_selected_series_to_case(request, 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) + 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) + return HttpResponse( + "You do not have permission to edit the destination case.", status=403 + ) ids = request.POST.getlist("series-ids") if not ids: @@ -10049,12 +10889,17 @@ def move_selected_series_to_case(request, case_pk): ) next_sort_order = ( - SeriesDetail.objects.filter(case=target_case).aggregate(max_sort=Max("sort_order"))["max_sort"] or -1 + 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() + link_exists = SeriesDetail.objects.filter( + case=target_case, series=series + ).exists() if not link_exists: SeriesDetail.objects.create( case=target_case, @@ -10070,6 +10915,7 @@ def move_selected_series_to_case(request, case_pk): 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) @@ -10081,9 +10927,12 @@ def reorder_series(request, case_pk): 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) + 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): """ Display and manage a linked-case chain. @@ -10138,7 +10987,9 @@ def linked_cases_overview(request, case_id): case_editor_apply_field_choices = [ ( field_name, - str(Case._meta.get_field(field_name).verbose_name).replace("_", " ").title(), + str(Case._meta.get_field(field_name).verbose_name) + .replace("_", " ") + .title(), ) for field_name in case_editor_apply_fields ] @@ -10146,15 +10997,12 @@ def linked_cases_overview(request, case_id): case_editor_formset_factory = modelformset_factory(Case, form=CaseForm, extra=0) def build_case_editor_formset(data=None): - queryset = ( - Case.objects.filter(pk__in=editable_chain_case_ids) - .prefetch_related( - "subspecialty", - "condition", - "presentation", - "pathological_process", - "procedures", - ) + queryset = Case.objects.filter(pk__in=editable_chain_case_ids).prefetch_related( + "subspecialty", + "condition", + "presentation", + "pathological_process", + "procedures", ) formset = case_editor_formset_factory( data=data, @@ -10185,12 +11033,16 @@ def linked_cases_overview(request, case_id): if form_action == "case_series_editor": if not editable_chain_case_ids: - raise PermissionDenied("You do not have permission to edit this case chain.") + raise PermissionDenied( + "You do not have permission to edit this case chain." + ) show_case_editor = True case_editor_selected_field = request.POST.get("apply_field", "") case_editor_selected_source = request.POST.get("apply_from_case", "") - case_editor_formset, case_editor_rows = build_case_editor_formset(request.POST) + case_editor_formset, case_editor_rows = build_case_editor_formset( + request.POST + ) if case_editor_formset.is_valid(): case_editor_formset.save() @@ -10198,41 +11050,77 @@ def linked_cases_overview(request, case_id): applied_count = 0 if case_editor_selected_field and case_editor_selected_source: if case_editor_selected_field not in case_editor_apply_fields: - messages.error(request, "Select a valid field to apply across the linked series.") + messages.error( + request, + "Select a valid field to apply across the linked series.", + ) else: source_case = Case.objects.filter( pk=case_editor_selected_source, pk__in=editable_chain_case_ids, ).first() if source_case is None: - messages.error(request, "Select a valid source case for field propagation.") + messages.error( + request, + "Select a valid source case for field propagation.", + ) else: - target_cases = Case.objects.filter(pk__in=editable_chain_case_ids).exclude(pk=source_case.pk) - case_field = Case._meta.get_field(case_editor_selected_field) + target_cases = Case.objects.filter( + pk__in=editable_chain_case_ids + ).exclude(pk=source_case.pk) + case_field = Case._meta.get_field( + case_editor_selected_field + ) if case_field.many_to_many: - source_values = list(getattr(source_case, case_editor_selected_field).all()) + source_values = list( + getattr( + source_case, case_editor_selected_field + ).all() + ) for target_case in target_cases: - getattr(target_case, case_editor_selected_field).set(source_values) + getattr( + target_case, case_editor_selected_field + ).set(source_values) applied_count += 1 else: - source_value = getattr(source_case, case_editor_selected_field) + source_value = getattr( + source_case, case_editor_selected_field + ) for target_case in target_cases: - if getattr(target_case, case_editor_selected_field) != source_value: - setattr(target_case, case_editor_selected_field, source_value) - target_case.save(update_fields=[case_editor_selected_field]) + if ( + getattr(target_case, case_editor_selected_field) + != source_value + ): + setattr( + target_case, + case_editor_selected_field, + source_value, + ) + target_case.save( + update_fields=[case_editor_selected_field] + ) applied_count += 1 if applied_count: - messages.success(request, f"Applied {case_editor_selected_field} to {applied_count} linked case(s).") + messages.success( + request, + f"Applied {case_editor_selected_field} to {applied_count} linked case(s).", + ) messages.success(request, "Saved linked case editor changes.") - return redirect(f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_editor=1") + return redirect( + f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_editor=1" + ) - messages.error(request, "Please correct the errors in the linked case editor form.") + messages.error( + request, "Please correct the errors in the linked case editor form." + ) elif form_action == "reorder_cases": if not case.check_user_can_edit(request.user): - raise PermissionDenied("You do not have permission to edit this case chain.") + raise PermissionDenied( + "You do not have permission to edit this case chain." + ) # Get new order from POST order = [] @@ -10244,29 +11132,36 @@ def linked_cases_overview(request, case_id): if set(order) == set(chain_case_ids): from django.db import transaction + with transaction.atomic(): cases = list(Case.objects.filter(pk__in=chain_case_ids)) for c in cases: c.previous_case = None c.save(update_fields=["previous_case"]) - + cases_by_id = {c.pk: c for c in cases} for i, pk in enumerate(order): if i > 0: current_c = cases_by_id[pk] - prev_c = cases_by_id[order[i-1]] + prev_c = cases_by_id[order[i - 1]] current_c.previous_case = prev_c current_c.save(update_fields=["previous_case"]) - + messages.success(request, "Saved case chain order.") - return redirect(f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_reorder=1") + return redirect( + f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_reorder=1" + ) else: messages.error(request, "Invalid case ordering submitted.") - return redirect(f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_reorder=1") + return redirect( + f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_reorder=1" + ) else: if not case.check_user_can_edit(request.user): - raise PermissionDenied("You do not have permission to edit this case chain.") + raise PermissionDenied( + "You do not have permission to edit this case chain." + ) show_study_dates_editor = True @@ -10275,11 +11170,17 @@ def linked_cases_overview(request, case_id): if apply_to_all_from: source_case = next( - (linked_case for linked_case in chain_cases if str(linked_case.pk) == apply_to_all_from), + ( + linked_case + for linked_case in chain_cases + if str(linked_case.pk) == apply_to_all_from + ), None, ) if source_case and source_case.check_user_can_edit(request.user): - source_date = _parse_study_date(request.POST.get(f"study_date_{source_case.pk}")) + source_date = _parse_study_date( + request.POST.get(f"study_date_{source_case.pk}") + ) for linked_case in chain_cases: if not linked_case.check_user_can_edit(request.user): continue @@ -10287,20 +11188,28 @@ def linked_cases_overview(request, case_id): linked_case.study_date = source_date linked_case.save(update_fields=["study_date"]) updated_cases += 1 - messages.success(request, f"Applied the study date to {updated_cases} case(s).") + messages.success( + request, f"Applied the study date to {updated_cases} case(s)." + ) else: for linked_case in chain_cases: if not linked_case.check_user_can_edit(request.user): continue - raw_value = request.POST.get(f"study_date_{linked_case.pk}", "").strip() + raw_value = request.POST.get( + f"study_date_{linked_case.pk}", "" + ).strip() new_date = _parse_study_date(raw_value) if linked_case.study_date != new_date: linked_case.study_date = new_date linked_case.save(update_fields=["study_date"]) updated_cases += 1 - messages.success(request, f"Updated {updated_cases} linked case date(s).") + messages.success( + request, f"Updated {updated_cases} linked case date(s)." + ) - return redirect(f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_dates=1") + return redirect( + f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_dates=1" + ) if show_case_editor and editable_chain_case_ids: case_editor_formset, case_editor_rows = build_case_editor_formset() @@ -10308,7 +11217,11 @@ def linked_cases_overview(request, case_id): linked_case_rows = [] case_relative_labels = {} anchor_date = next( - (linked_case.get_effective_study_date() for linked_case in chain_cases if linked_case.get_effective_study_date()), + ( + linked_case.get_effective_study_date() + for linked_case in chain_cases + if linked_case.get_effective_study_date() + ), None, ) for linked_case in chain_cases: @@ -10362,6 +11275,7 @@ def linked_cases_overview(request, case_id): }, ) + @login_required def add_series_to_case(request, series_pk): if request.method == "POST": @@ -10378,11 +11292,18 @@ def add_series_to_case(request, series_pk): # If this is an HTMX request, return the updated cases list partial so the # client can replace the #cases-list innerHTML. Otherwise return a simple message. if request.headers.get("Hx-Request") or request.META.get("HTTP_HX_REQUEST"): - return render(request, 'atlas/partials/series_cases_list.html', {'series': series, 'can_edit': case.check_user_can_edit(request.user)}) + return render( + request, + "atlas/partials/series_cases_list.html", + {"series": series, "can_edit": case.check_user_can_edit(request.user)}, + ) - return HttpResponse(f"Series {series_pk} added to case {case.title} ({case_id}).") + return HttpResponse( + f"Series {series_pk} added to case {case.title} ({case_id})." + ) return HttpResponse("Invalid request.") + @login_required def remove_series_from_case(request, series_pk, case_pk): if request.method == "POST": @@ -10398,28 +11319,41 @@ def remove_series_from_case(request, series_pk, case_pk): # If this is an HTMX request, return the updated cases list partial so the # client can replace the #cases-list innerHTML. Otherwise return a simple message. if request.headers.get("Hx-Request") or request.META.get("HTTP_HX_REQUEST"): - return render(request, 'atlas/partials/series_cases_list.html', {'series': series, 'can_edit': case.check_user_can_edit(request.user)}) + return render( + request, + "atlas/partials/series_cases_list.html", + {"series": series, "can_edit": case.check_user_can_edit(request.user)}, + ) return HttpResponse("Removed") return HttpResponse("Invalid request.") + @login_required def series_finding_details(request, finding_id): finding = get_object_or_404(SeriesFinding, pk=finding_id) - return render(request, 'atlas/series_finding_details.html', {'finding': finding}) + return render(request, "atlas/series_finding_details.html", {"finding": finding}) @login_required def series_finding_preview(request, finding_id): """Return a small HTML fragment summarising a SeriesFinding for popups/modals.""" finding = get_object_or_404(SeriesFinding, pk=finding_id) - return render(request, 'atlas/partials/_seriesfinding_preview.html', {'finding': finding}) + return render( + request, "atlas/partials/_seriesfinding_preview.html", {"finding": finding} + ) + @login_required def series_finding_related(request, series_id): series = get_object_or_404(Series, pk=series_id) findings = series.get_related_findings() - return render(request, 'atlas/series_finding_related.html', {'series': series, 'findings': findings}) + return render( + request, + "atlas/series_finding_related.html", + {"series": series, "findings": findings}, + ) + def seriesfinding_migration_status(request): """ @@ -10429,16 +11363,21 @@ def seriesfinding_migration_status(request): findings = SeriesFinding.objects.all().select_related() findings_status = [] for finding in findings: - findings_status.append({ - "id": finding.id, - "str": str(finding), - "annotation_json_3d": bool(finding.annotation_json_3d), - "viewer_state_3d": bool(finding.viewer_state_3d), - "series": finding.series - }) - return render(request, "atlas/seriesfinding_migration_status.html", { - "findings_status": findings_status - }) + findings_status.append( + { + "id": finding.id, + "str": str(finding), + "annotation_json_3d": bool(finding.annotation_json_3d), + "viewer_state_3d": bool(finding.viewer_state_3d), + "series": finding.series, + } + ) + return render( + request, + "atlas/seriesfinding_migration_status.html", + {"findings_status": findings_status}, + ) + @login_required @require_POST @@ -10453,7 +11392,11 @@ def series_bulk_delete(request): response = HttpResponse("No valid series selected to delete.", status=400) return response if request.htmx else redirect("atlas:series_view") - forbidden_series = [series.pk for series in series_to_delete if not series.check_user_can_edit(request.user)] + forbidden_series = [ + series.pk + for series in series_to_delete + if not series.check_user_can_edit(request.user) + ] if forbidden_series: response = HttpResponse( f"You do not have permission to delete {len(forbidden_series)} selected series.", @@ -10470,8 +11413,11 @@ def series_bulk_delete(request): return response return redirect(request.META.get("HTTP_REFERER") or reverse("atlas:series_view")) + @user_is_collection_author_or_atlas_editor -def collection_case_displaysetup(request, collection_id, case_number=None, case_id=None): +def collection_case_displaysetup( + request, collection_id, case_number=None, case_id=None +): """ View to set up the display options for a specific case in a collection. This can include setting which series to display, annotations, etc. @@ -10481,7 +11427,9 @@ def collection_case_displaysetup(request, collection_id, case_number=None, case_ try: # If a case_id was passed explicitly, try that first if case_id is not None: - casedetail = get_object_or_404(CaseDetail, case__pk=case_id, collection__pk=collection_id) + casedetail = get_object_or_404( + CaseDetail, case__pk=case_id, collection__pk=collection_id + ) else: # Otherwise the route most likely supplied case_number; try resolving by index collection = get_object_or_404(CaseCollection, pk=collection_id) @@ -10497,14 +11445,18 @@ def collection_case_displaysetup(request, collection_id, case_number=None, case_ if data.get("reset_viewerstate"): casedetail.default_viewerstate = None casedetail.save() - return HttpResponse("Viewer state reset to default.") + return HttpResponse( + "Viewer state reset to default." + ) viewerstate = data.get("viewerstate") if viewerstate is not None: casedetail.default_viewerstate = viewerstate casedetail.save() return HttpResponse("Viewer state saved.") else: - return HttpResponse("No viewer state provided.", status=400) + return HttpResponse( + "No viewer state provided.", status=400 + ) collection = casedetail.collection @@ -10515,132 +11467,162 @@ def collection_case_displaysetup(request, collection_id, case_number=None, case_ previous = collection.get_previous_case(casedetail.case) next = collection.get_next_case(casedetail.case) - return render(request, "atlas/collection_case_displaysetup.html", { - "casedetail": casedetail, - "collection": collection, - "case_number": case_number, - "case_count": case_count, - "previous": previous, - "next": next, - "collection_length": case_count, - - }) + return render( + request, + "atlas/collection_case_displaysetup.html", + { + "casedetail": casedetail, + "collection": collection, + "case_number": case_number, + "case_count": case_count, + "previous": previous, + "next": next, + "collection_length": case_count, + }, + ) @login_required def multiuser_dashboard(request): import datetime from django.utils import timezone - - my_sessions = MultiuserSession.objects.filter(creator=request.user).order_by("-created_at") + + my_sessions = MultiuserSession.objects.filter(creator=request.user).order_by( + "-created_at" + ) recent_limit = timezone.now() - datetime.timedelta(hours=24) - active_sessions = MultiuserSession.objects.exclude(creator=request.user).filter(created_at__gte=recent_limit).order_by("-created_at") - - return render(request, "atlas/multiuser_dashboard.html", { - "my_sessions": my_sessions, - "active_sessions": active_sessions, - }) + active_sessions = ( + MultiuserSession.objects.exclude(creator=request.user) + .filter(created_at__gte=recent_limit) + .order_by("-created_at") + ) + + return render( + request, + "atlas/multiuser_dashboard.html", + { + "my_sessions": my_sessions, + "active_sessions": active_sessions, + }, + ) @staff_member_required def multiuser_create_session(request): name = request.POST.get("name", "").strip() - session = MultiuserSession.objects.create( - name=name, - creator=request.user - ) + session = MultiuserSession.objects.create(name=name, creator=request.user) return redirect("atlas:multiuser_session_detail", session_id=session.id) @login_required def multiuser_session_detail(request, session_id): session = get_object_or_404(MultiuserSession, id=session_id) - is_manager = (request.user == session.creator) - - return render(request, "atlas/multiuser_session_detail.html", { - "session": session, - "is_manager": is_manager, - "active_case": session.active_case, - }) + is_manager = request.user == session.creator + + return render( + request, + "atlas/multiuser_session_detail.html", + { + "session": session, + "is_manager": is_manager, + "active_case": session.active_case, + }, + ) @login_required def multiuser_search_cases(request, session_id): from django.http import HttpResponseForbidden + session = get_object_or_404(MultiuserSession, id=session_id) if request.user != session.creator and not request.user.is_staff: return HttpResponseForbidden("Only the session manager can search cases.") - + q = request.GET.get("q", "").strip() from .helpers import get_cases_available_to_user + qs = get_cases_available_to_user(request.user) if q: qs = qs.filter(title__icontains=q) - + cases = qs.order_by("title")[:15] - return render(request, "atlas/partials/_multiuser_case_search_results.html", { - "cases": cases, - "session": session, - }) + return render( + request, + "atlas/partials/_multiuser_case_search_results.html", + { + "cases": cases, + "session": session, + }, + ) @login_required def multiuser_case_content_partial(request, session_id): session = get_object_or_404(MultiuserSession, id=session_id) case = session.active_case - + if not case: - return render(request, "atlas/partials/_multiuser_empty_case.html", { - "session": session, - "is_manager": (request.user == session.creator), - }) - + return render( + request, + "atlas/partials/_multiuser_empty_case.html", + { + "session": session, + "is_manager": (request.user == session.creator), + }, + ) + named_stacks_json = case.get_case_named_stacks() - - return render(request, "atlas/partials/_multiuser_case_content.html", { - "session": session, - "case": case, - "named_stacks_json": named_stacks_json, - "is_manager": (request.user == session.creator), - }) + + return render( + request, + "atlas/partials/_multiuser_case_content.html", + { + "session": session, + "case": case, + "named_stacks_json": named_stacks_json, + "is_manager": (request.user == session.creator), + }, + ) @login_required @require_POST def multiuser_load_case(request, session_id): from django.http import HttpResponseForbidden + session = get_object_or_404(MultiuserSession, id=session_id) if request.user != session.creator and not request.user.is_staff: return HttpResponseForbidden("Only the session manager can load cases.") - + case_id = request.POST.get("case_id") if not case_id: return HttpResponse("No case selected", status=400) - + from .helpers import get_cases_available_to_user + case = get_object_or_404(get_cases_available_to_user(request.user), pk=case_id) - + session.active_case = case session.save() - + # Broadcast to channels group from channels.layers import get_channel_layer from asgiref.sync import async_to_sync - + channel_layer = get_channel_layer() if channel_layer: async_to_sync(channel_layer.group_send)( - f"multiuser_{session.id}", - { - "type": "change_case", - "case_id": case.id - } + f"multiuser_{session.id}", {"type": "change_case", "case_id": case.id} ) - + named_stacks_json = case.get_case_named_stacks() - return render(request, "atlas/partials/_multiuser_case_content.html", { - "session": session, - "case": case, - "named_stacks_json": named_stacks_json, - "is_manager": True, - }) + return render( + request, + "atlas/partials/_multiuser_case_content.html", + { + "session": session, + "case": case, + "named_stacks_json": named_stacks_json, + "is_manager": True, + }, + ) diff --git a/longs/migrations/0039_useranswer_marker_feedback.py b/longs/migrations/0039_useranswer_marker_feedback.py new file mode 100644 index 00000000..371ed11e --- /dev/null +++ b/longs/migrations/0039_useranswer_marker_feedback.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-04 22:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('longs', '0038_useranswer_time_answered'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='marker_feedback', + field=models.TextField(blank=True, help_text='Feedback for the marker, this is optional and will NOT be shown to the candidate.', null=True), + ), + ] diff --git a/shorts/migrations/0015_useranswer_marker_feedback.py b/shorts/migrations/0015_useranswer_marker_feedback.py new file mode 100644 index 00000000..e823d6fe --- /dev/null +++ b/shorts/migrations/0015_useranswer_marker_feedback.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-04 22:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0014_useranswer_time_answered'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='marker_feedback', + field=models.TextField(blank=True, help_text='Feedback for the marker, this is optional and will NOT be shown to the candidate.', null=True), + ), + ] diff --git a/todo b/todo index 0d003544..831b0530 100644 --- a/todo +++ b/todo @@ -1 +1,21 @@ -RTS: we should show a refresh button for each of the exam/packet sections (as well as a global refresh that refreshes all) \ No newline at end of file +RTS: we should show a refresh button for each of the exam/packet sections (as well as a global refresh that refreshes all) + +When editing / viewing a case in the atlas it is currently possible to associate resources that have already been created. it should also be possible to create and attach the resource inline. + +When editing a case as part of a casecollection we should redirect back to the case as part of the casecollection when it is saved / cancelled. + +We need to optimise the case series update view. This currently hangs on the production site (likely due to the large number of series). + +Running the docker local-up script does not always shut down the currently running containsers: +(rad) ⋊> ~/p/rad on master ⨯ ./scripts/local-up.sh -n 23:07:18 +Starting compose with COMPOSE_ENV=dev (using .env.dev) +Ensuring loki and logs folders exist under /home/ross/penracourses/rad/docker and /home/ross/penracourses/rad/logs +Attempting to chown loki folders to UID 10001 (may prompt for sudo password) +[sudo] password for ross: +[+] Running 5/5 + ✔ Container docker-nginx-1 Stopped 0.2s + ✔ Container docker-worker-1 Stopped 0.5s + ✔ Container docker-web-1 Stopped 10.3s + ✔ Container docker-redis-1 Stopped 0.2s + ✔ Network docker_default Removed 0.0s +Error response from daemon: error while removing network: network docker_default has active endpoints (name:"docker-cache-1" id:"4650361118ce", name:"docker-db-1" id:"2a82b22517d0") \ No newline at end of file