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'
{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'{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."
+ ""
''
f"{created_case_links}"
- '
'
+ ""
)
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