Files
penracourses/atlas/views.py
T
Ross a573ed78a4 Enhance Supervisor Dashboard and Trainee Feedback Features
- Redesigned the supervisor overview page to include a header card with supervisor details and trainee statistics.
- Implemented a tabbed interface for viewing trainees and their recent attempts.
- Improved the trainee detail page with a profile card and a table for displaying attempt history.
- Added a new view for supervisors to provide feedback on case collections, including outstanding feedback items and previous conversations.
- Updated URL routing to support new feedback functionality.
- Enhanced backend logic to ensure proper sharing permissions for exam attempts and case collections.
- Added comprehensive tests for supervisor access to trainee data and feedback functionalities.
2026-06-15 12:28:00 +01:00

10454 lines
387 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import pprint
import csv
import random
import copy
import io
import datetime
from datetime import timedelta
from typing import Any
from dal import autocomplete
from django.db.models.base import Model as Model
from django.db.models.query import QuerySet
from django.db.models import Prefetch
from django.db.models import Q
from django.db import transaction
from django.shortcuts import render, get_object_or_404, redirect
from django import forms
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.html import escape
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_http_methods
from django.template.loader import render_to_string
from django.db.models import Count, Max
from django.template.defaultfilters import filesizeformat
# from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User
from django.contrib import messages
from django.core.exceptions import (
FieldError,
ObjectDoesNotExist,
PermissionDenied,
ViewDoesNotExist,
)
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import View
from django.views.generic.detail import DetailView
import pydicom
from pydicom.uid import generate_uid
from generic.mixins import SuperuserRequiredMixin, UserConfigurablePaginationMixin
from django.contrib.admin.views.decorators import staff_member_required
from django.core.paginator import Paginator
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from django.views.generic import ListView
from django_filters.views import FilterView
from django_tables2 import SingleTableMixin
from .filters import ResourceFilter
from .tables import ResourceTable
from django.views.decorators.csrf import csrf_exempt
from django.urls import reverse_lazy, reverse
from django.urls.exceptions import NoReverseMatch
from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType
from django.forms.widgets import HiddenInput
from django.forms import modelformset_factory
import zipfile
import os
from django.core.files.base import ContentFile
from django.core.exceptions import PermissionDenied
from generic.models import CidUser, CidUserExam, CimarCase, Site, get_next_cid
from atlas.helpers import get_cases_available_to_user
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
from .forms import (
AddCollectionToCaseForm,
CaseAuthorForm,
CaseCollectionAuthorForm,
CaseCollectionCaseFormSet,
CaseCollectionForm,
CaseCollectionUpdateCaseForm,
CaseDetailForm,
CaseQuestionForm,
MoveSelectedSeriesForm,
CaseDisplaySetForm,
CaseForm,
CaseResourceFormSet,
CaseUpdateSeriesForm,
CidQuestionAnswerForm,
CidReportAnswerForm,
CidReportAnswerMarkForm,
ConditionAutocompleteForm,
ConditionForm,
ConditionRelationshipForm,
ExamGroupsForm,
FindingForm,
JsonAnswerForm,
#ModelPriorCaseForm,
PriorCaseForm,
QuestionSchemaForm,
ResourceForm,
CaseReviewMessageForm,
SelfReviewForm,
SeriesAuthorForm,
SeriesForm,
SeriesImageFormSet,
SeriesFormSet,
SeriesFindingForm,
CaseDifferentialFormSet,
StructureForm,
SubspecialtyForm,
PresentationForm,
PathologicalProcessForm,
ProcedureForm,
UserQuestionAnswerForm,
UserReportAnswerForm,
)
from generic.models import UserHiddenItem
from .models import (
Case,
CaseCollection,
CaseDetail,
CaseDisplaySet,
CasePrior,
CaseResource,
CaseReviewMessage,
CidReportAnswer,
Condition,
Differential,
PathologicalProcess,
Presentation,
QuestionSchema,
Resource,
SelfReview,
Series,
Examination,
Finding,
SeriesDetail,
Structure,
Subspecialty,
SeriesFinding,
SeriesImage,
UncategorisedDicom,
UserReportAnswer,
PrerequisiteRequired
, APIToken,
_build_declared_series_groups,
)
from .models import Procedure
from .models import NormalCase
from .tables import (
CaseCollectionTable,
CaseTable,
ConditionTable,
FindingTable,
PathologicalProcessTable,
PresentationTable,
QuestionSchemaTable,
SeriesTable,
StructureTable,
SubspecialtyTable,
)
from .filters import (
CaseCollectionFilter,
CaseFilter,
ConditionFilter,
FindingFilter,
PathologicalProcessFilter,
PresentationFilter,
QuestionSchemaFilter,
SeriesFilter,
StructureFilter,
SubspecialtyFilter,
NormalCaseFilter,
)
def _parse_study_date(value):
if not value:
return None
try:
parsed_value = str(value)
for date_format in ("%Y%m%d", "%Y-%m-%d"):
try:
return datetime.datetime.strptime(parsed_value, date_format).date()
except ValueError:
continue
return None
except (TypeError, ValueError):
return None
def _extract_study_date_from_dicoms(dicoms):
for dicom in dicoms.iterator():
tags = dicom.basic_dicom_tags or {}
for key in ("StudyDate", "AcquisitionDate", "SeriesDate"):
study_date = _parse_study_date(tags.get(key))
if study_date:
return study_date
return None
def _format_relative_date_offset(days_delta):
if days_delta is None:
return None
if days_delta == 0:
return {"text": "Day 0", "unit": "day", "value": 0}
sign_prefix = "+" if days_delta > 0 else "-"
absolute_days = abs(days_delta)
if absolute_days >= 365:
value = round(absolute_days / 365.25, 1)
unit = "year"
elif absolute_days >= 30:
value = round(absolute_days / 30.4375, 1)
unit = "month"
else:
value = absolute_days
unit = "day"
if isinstance(value, float) and value.is_integer():
value = int(value)
suffix = "s" if value != 1 else ""
return {
"text": f"{sign_prefix}{value} {unit}{suffix}",
"unit": unit,
"value": value,
}
from .tasks import push_case_to_cimar_task, series_downsample_task, series_reconstruct_task
try:
from django_tasks import TaskResultStatus
except ImportError:
from enum import Enum
class TaskResultStatus(Enum):
READY = "READY"
RUNNING = "RUNNING"
FAILED = "FAILED"
SUCCESSFUL = "SUCCESSFUL"
try:
from django_tasks_db.models import DBTaskResult
except ImportError:
DBTaskResult = None
from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView
# from autocomplete import HTMXAutoComplete
from .decorators import (
user_is_author_or_atlas_editor,
user_has_case_view_access,
user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access,
user_is_atlas_editor,
user_is_author_or_atlas_series_checker,
user_is_atlas_marker,
user_is_collection_author_or_atlas_editor,
)
from collections import defaultdict
import json
import statistics
import plotly.express as px
from django.core.cache import cache
from django.conf import settings
from helpers.images import compare_dicom_datasets, image_as_base64, print_dicom
import logging
from copy import deepcopy
from django.forms.models import model_to_dict
from generic.views import (
AuthorRequiredMixin,
ExamCloneMixin,
ExamGroupsUpdateBase,
ExamViews,
SeriesImagesZipViewBase,
)
from reversion.views import RevisionMixin
import reversion
import os
import difflib
from helpers.cimar import CimarAPI
from loguru import logger
@login_required
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("<div class=\"alert alert-danger\">Permission denied</div>")
images_removed = []
for n, image in enumerate(series.get_images()):
if n >= int(start) and n <= int(end):
continue
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
# Recalculate stored series size
try:
series.get_total_image_size(refresh=True)
except Exception:
# ignore errors when recalculating
pass
# Render a simple message fragment for HTMX swap
html = render_to_string(
"atlas/partials/truncate_result.html",
{
"images_removed": images_removed,
"images_downsampled": [],
"series": series,
"remove_empty_dicoms": False,
"downsample_pixels": False,
},
request=request,
)
return HttpResponse(html)
def _read_series_image_dataset(series_image):
if not series_image.image:
return None
try:
series_image.image.open("rb")
series_image.image.seek(0)
return pydicom.dcmread(series_image.image, force=True)
except Exception as exc:
logger.warning("Failed to read DICOM for image {}: {}", series_image.pk, exc)
return None
def _is_empty_dicom(ds):
try:
px = ds.pixel_array
return bool((px == 0).all())
except Exception:
return False
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)
return arr[np.ix_(row_idx, col_idx)]
def _stamp_derived_dataset(
ds,
*,
series_instance_uid=None,
source_series_instance_uid=None,
series_description=None,
derivation_description=None,
image_type=None,
regenerate_sop_instance_uid=True,
stamp_series_datetime=True,
):
now = timezone.now()
date_str = now.strftime("%Y%m%d")
time_str = now.strftime("%H%M%S.%f")[:13]
source_sop_instance_uid = getattr(ds, "SOPInstanceUID", None)
source_sop_class_uid = getattr(ds, "SOPClassUID", None)
if regenerate_sop_instance_uid:
ds.SOPInstanceUID = generate_uid()
if series_instance_uid:
ds.SeriesInstanceUID = series_instance_uid
if series_description:
ds.SeriesDescription = series_description
ds.ImageType = image_type or ["DERIVED", "SECONDARY"]
if derivation_description:
ds.DerivationDescription = derivation_description
if source_series_instance_uid:
series_ref = pydicom.dataset.Dataset()
series_ref.SeriesInstanceUID = str(source_series_instance_uid)
ds.ReferencedSeriesSequence = pydicom.sequence.Sequence([series_ref])
if source_sop_instance_uid:
source_ref = pydicom.dataset.Dataset()
if source_sop_class_uid:
source_ref.ReferencedSOPClassUID = str(source_sop_class_uid)
source_ref.ReferencedSOPInstanceUID = str(source_sop_instance_uid)
ds.SourceImageSequence = pydicom.sequence.Sequence([source_ref])
ds.ContentDate = date_str
ds.ContentTime = time_str
ds.InstanceCreationDate = date_str
ds.InstanceCreationTime = time_str
if stamp_series_datetime:
ds.SeriesDate = date_str
ds.SeriesTime = time_str
return ds
def _downsample_dicom_dataset(
ds,
reduction_pct,
*,
series_instance_uid=None,
source_series_instance_uid=None,
series_description=None,
):
if reduction_pct <= 0 or reduction_pct >= 100:
raise ValueError("Downsample percentage must be between 1 and 99")
factor = (100 - reduction_pct) / 100.0
px = ds.pixel_array
if px.ndim != 2:
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"
_stamp_derived_dataset(
ds,
series_instance_uid=series_instance_uid,
source_series_instance_uid=source_series_instance_uid,
series_description=series_description,
derivation_description=derivation_description,
image_type=["DERIVED", "SECONDARY"],
)
if hasattr(ds, "PixelSpacing") and ds.PixelSpacing and len(ds.PixelSpacing) >= 2:
row_spacing = _safe_float(ds.PixelSpacing[0], 1.0)
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:
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)]
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.Rows = int(target_rows)
ds.Columns = int(target_cols)
ds.PixelData = resized.tobytes()
ds.pop("SmallestImagePixelValue", None)
ds.pop("LargestImagePixelValue", None)
ds.pop("PixelAspectRatio", None)
return ds
def _create_series_derivative(source_series, description_suffix):
derived = Series.objects.get(pk=source_series.pk)
derived.pk = None
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.modified = Series.SeriesModifiedChocies.RE
derived.save()
derived.case.set(source_series.case.all())
derived.author.set(source_series.author.all())
return derived
def _series_image_urls_json(series):
image_url_array_and_count = series.get_image_url_array_and_count()
try:
urls = json.loads(image_url_array_and_count[0])
except Exception:
urls = []
return urls
def _safe_float(value, default=None):
try:
if value is None:
return default
return float(value)
except Exception:
return default
def _infer_native_spacings(dicom_items):
"""Infer native (row, col, z) spacing in mm from a list of (image, ds, arr)."""
first_ds = dicom_items[0][1]
row_spacing = 1.0
col_spacing = 1.0
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)
z_spacing = _safe_float(getattr(first_ds, "SpacingBetweenSlices", None), None)
if z_spacing is None:
z_spacing = _safe_float(getattr(first_ds, "SliceThickness", None), None)
if z_spacing is None and len(dicom_items) > 1:
try:
p0 = dicom_items[0][1].ImagePositionPatient
p1 = dicom_items[1][1].ImagePositionPatient
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)))
except Exception:
z_spacing = None
if z_spacing is None or z_spacing <= 0:
z_spacing = 1.0
return float(row_spacing), float(col_spacing), float(z_spacing)
def _extract_recon_geometry(dicom_items):
"""Build sorted geometry context for reconstruction from (image, ds, arr) items."""
import numpy as np
row_spacing, col_spacing, native_z_spacing = _infer_native_spacings(dicom_items)
first_ds = dicom_items[0][1]
row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
col_dir = np.array([0.0, 1.0, 0.0], dtype=float)
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)
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)
if np.linalg.norm(row_dir) == 0 or np.linalg.norm(col_dir) == 0:
row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
col_dir = np.array([0.0, 1.0, 0.0], dtype=float)
row_dir = row_dir / np.linalg.norm(row_dir)
col_dir = col_dir / np.linalg.norm(col_dir)
normal_dir = np.cross(row_dir, col_dir)
if np.linalg.norm(normal_dir) == 0:
normal_dir = np.array([0.0, 0.0, 1.0], dtype=float)
normal_dir = normal_dir / np.linalg.norm(normal_dir)
decorated = []
for idx, item in enumerate(dicom_items):
ds = item[1]
ipp = None
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)
except Exception:
ipp = None
if ipp is None:
ipp = np.array([0.0, 0.0, idx * native_z_spacing], dtype=float)
proj = float(np.dot(ipp, normal_dir))
decorated.append((proj, ipp, item))
decorated.sort(key=lambda x: x[0])
sorted_items = [d[2] for d in decorated]
sorted_ipps = [d[1] for d in decorated]
sorted_proj = np.array([d[0] for d in decorated], dtype=float)
if len(sorted_proj) > 1:
diffs = np.diff(sorted_proj)
valid_diffs = np.abs(diffs[np.abs(diffs) > 1e-6])
if valid_diffs.size > 0:
native_z_spacing = float(np.median(np.abs(valid_diffs)))
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)
return {
"sorted_items": sorted_items,
"source_positions_mm": source_positions_mm,
"origin_ipp": origin_ipp,
"row_dir": row_dir,
"col_dir": col_dir,
"normal_dir": normal_dir,
"row_spacing": float(row_spacing),
"col_spacing": float(col_spacing),
"native_z_spacing": float(native_z_spacing),
}
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
if target_spacing <= 0:
raise ValueError("Target spacing must be positive")
if slab_thickness <= 0:
raise ValueError("Slab thickness must be positive")
source_positions_mm = np.asarray(source_positions_mm, dtype=float)
if source_positions_mm.size != volume.shape[0]:
raise ValueError("Source position count must match volume depth")
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)
if centers.size == 0:
centers = np.array([start_pos], dtype=float)
half = slab_thickness / 2.0
slabs = []
for center in centers:
mask = np.abs(source_positions_mm - center) <= (half + 1e-6)
if not mask.any():
nearest = int(np.argmin(np.abs(source_positions_mm - center)))
slab = volume[nearest : nearest + 1, :, :]
else:
slab = volume[mask, :, :]
if mode == "max":
out = np.max(slab, axis=0)
elif mode == "min":
out = np.min(slab, axis=0)
else:
out = np.mean(slab, axis=0)
slabs.append(out)
out_volume = np.stack(slabs, axis=0)
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)
else:
out_volume = out_volume.astype(volume.dtype, copy=False)
return out_volume, centers
@login_required
@require_http_methods(["POST"])
def series_optimize_htmx(request, series_id):
"""Run disk optimization operations for a series.
Scope: Series editors only.
Supported operations:
- remove_empty
- downsample
- downsample_preview
- reconstruct
- truncate
"""
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return HttpResponse('<div class="alert alert-danger mb-0">Permission denied</div>')
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
if end < start:
return HttpResponse('<div class="alert alert-danger mb-0">Start must be less than or equal to end.</div>')
images = list(series.get_images())
bounded_images = [img for idx, img in enumerate(images) if idx >= start and idx <= end]
full_series_images = images
if operation == "truncate":
removed = []
for idx, image in enumerate(images):
if idx < start or idx > end:
image.removed = True
image.image = None
image.save()
removed.append(image.pk)
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
try:
series.get_total_image_size(refresh=True)
except Exception:
pass
new_total = int(series.get_image_count())
payload = (
f'<div id="truncate-range-payload" data-start="1" data-end="{new_total}" data-total="{new_total}"></div>'
)
return HttpResponse(
(
'<div class="alert alert-success mb-2">'
f'Truncate complete. Removed <strong>{len(removed)}</strong> images. '
f'Updated working range to <strong>1-{new_total}</strong>.'
'</div>'
f'{payload}'
)
)
if operation == "remove_empty":
removed = []
for image in bounded_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
if _is_empty_dicom(ds):
image.removed = True
image.image = None
image.save()
removed.append(image.pk)
if removed:
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
try:
series.get_total_image_size(refresh=True)
except Exception:
pass
return HttpResponse(
f'<div class="alert alert-success mb-0">Empty DICOM removal complete. Removed <strong>{len(removed)}</strong> empty images.</div>'
)
if operation in ("downsample", "downsample_preview"):
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}%")
created = 0
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
ds_out = _downsample_dicom_dataset(
ds,
downsample_pct,
series_instance_uid=preview_series.series_instance_uid,
source_series_instance_uid=series.series_instance_uid,
series_description=preview_series.description,
)
except Exception as 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.image.save(
f"preview_ds_{downsample_pct}_{image.pk}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
new_image.save()
created += 1
original_urls = _series_image_urls_json(series)
preview_urls = _series_image_urls_json(preview_series)
named_stacks = json.dumps(
{
"Original": original_urls,
f"Preview {downsample_pct}%": preview_urls,
}
)
escaped_named_stacks = escape(named_stacks)
return HttpResponse(
(
'<div class="alert alert-info mb-2">'
f'Downsample preview series created with <strong>{created}</strong> images. '
f'<a class="alert-link" target="_blank" href="{preview_series.get_absolute_url()}">Open preview series</a>.'
'</div>'
f'<div id="downsample-compare-payload" data-named-stacks="{escaped_named_stacks}"></div>'
)
)
task_result = series_downsample_task.enqueue(
series_id=series.pk,
user_id=request.user.pk,
downsample_pct=downsample_pct,
)
return HttpResponse(
(
'<div class="alert alert-info mb-2">'
'Downsample queued. This runs in the background to avoid request timeout.'
'</div>'
f'<div id="downsample-task-status" hx-get="{reverse("atlas:series_downsample_status", kwargs={"series_id": series.pk, "task_id": task_result.id})}" '
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
'<span class="small text-muted">Starting downsample task...</span></div></div>'
)
)
if operation == "reconstruct":
recon_planes = request.POST.getlist("recon_planes")
if not recon_planes:
return HttpResponse('<div class="alert alert-danger mb-0">Select at least one reconstruction plane.</div>')
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()
if recon_thickness_mode not in ("mean", "max", "min"):
return HttpResponse('<div class="alert alert-danger mb-0">Invalid thickness merge mode.</div>')
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('<div class="alert alert-danger mb-0">Slice thickness and spacing must be numeric values.</div>')
if slice_thickness_val is not None and slice_thickness_val <= 0:
return HttpResponse('<div class="alert alert-danger mb-0">Slice thickness must be greater than 0.</div>')
if slice_spacing_val is not None and slice_spacing_val <= 0:
return HttpResponse('<div class="alert alert-danger mb-0">Slice spacing must be greater than 0.</div>')
task_result = series_reconstruct_task.enqueue(
series_id=series.pk,
user_id=request.user.pk,
recon_planes=recon_planes,
slice_thickness_val=slice_thickness_val,
slice_spacing_val=slice_spacing_val,
recon_thickness_mode=recon_thickness_mode,
)
return HttpResponse(
(
'<div class="alert alert-info mb-2">'
'Reconstruction queued. This runs in the background to avoid request timeout.'
'</div>'
f'<div id="recon-task-status" hx-get="{reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id})}" '
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
'<span class="small text-muted">Starting reconstruction task...</span></div></div>'
f'<div id="recon-task-payload" data-task-id="{escape(str(task_result.id))}" data-status-url="{escape(reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id}))}"></div>'
)
)
return HttpResponse('<div class="alert alert-danger mb-0">Unknown optimize operation.</div>')
@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_reconstruct_advanced(request, series_id):
"""Advanced reconstruction planning page using DV3D MPR/crosshair workflow.
Scope: users with access to the source series.
Functionality: mounts DV3D in an MPR-focused layout, allows manual planning,
and submits reconstruction requests while preserving the existing series tools flow.
"""
series = get_object_or_404(Series, pk=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_ids = [f"wadouri:{url}" for url in image_urls]
context = {
"series": series,
"image_ids_json": json.dumps(image_ids),
}
return render(request, "atlas/series_reconstruct_advanced.html", context)
@login_required
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 = escape(target_id)
if not series.check_user_can_edit(request.user):
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>')
try:
task_result = series_reconstruct_task.get_result(task_id)
except NotImplementedError:
return HttpResponse(
f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
)
except Exception:
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>')
task_result.refresh()
state = task_result.status
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)
message = escape(str(progress.get("message", "Reconstruction is running...")))
pct = int((current / total) * 100) if total > 0 else 0
if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
return HttpResponse(
(
f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" hx-trigger="every 2s" hx-swap="outerHTML">'
f'<div class="small text-muted mb-1">{message}</div>'
'<div class="progress" style="height: 10px;">'
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
f'aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100"></div>'
'</div>'
f'<div class="small text-muted mt-1">{current}/{total} slices processed</div>'
'</div>'
)
)
if state == TaskResultStatus.SUCCESSFUL:
try:
payload = task_result.return_value if isinstance(task_result.return_value, dict) else {}
except ValueError:
payload = {}
created_series = payload.get("created_series", [])
if created_series:
links = " ".join(
[
f'<a class="alert-link me-2" target="_blank" href="{escape(item.get("url", "#"))}">{escape(item.get("description", str(item.get("id", "series"))))}</a>'
for item in created_series
]
)
return HttpResponse(
(
f'<div id="{target_id}" class="alert alert-success mb-0">'
f'Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}'
'</div>'
)
)
return HttpResponse(f'<div id="{target_id}" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
if state == TaskResultStatus.FAILED:
err = "Unknown failure"
if task_result.errors:
err = escape(task_result.errors[-1].traceback.splitlines()[-1])
return HttpResponse(
f'<div id="{target_id}" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
)
return HttpResponse(
f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
)
@login_required
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 = escape(target_id)
if not series.check_user_can_edit(request.user):
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>')
try:
task_result = series_downsample_task.get_result(task_id)
except NotImplementedError:
return HttpResponse(
f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
)
except Exception:
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>')
task_result.refresh()
state = task_result.status
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)
message = escape(str(progress.get("message", "Downsample is running...")))
pct = int((current / total) * 100) if total > 0 else 0
if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
return HttpResponse(
(
f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" hx-trigger="every 2s" hx-swap="outerHTML">'
f'<div class="small text-muted mb-1">{message}</div>'
'<div class="progress" style="height: 10px;">'
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
f'aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100"></div>'
'</div>'
f'<div class="small text-muted mt-1">{current}/{total} images processed</div>'
'</div>'
)
)
if state == TaskResultStatus.SUCCESSFUL:
payload = task_result.return_value or {}
updated = int(payload.get("updated_images", 0) or 0)
downsample_pct = escape(str(payload.get("downsample_pct", "")))
return HttpResponse(
f'<div id="{target_id}" class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{updated}</strong> images while preserving original hashes.</div>'
)
if state == TaskResultStatus.FAILED:
err = "Unknown failure"
if task_result.errors:
err = escape(task_result.errors[-1].traceback.splitlines()[-1])
return HttpResponse(
f'<div id="{target_id}" class="alert alert-danger mb-0">Downsample failed: {err}</div>'
)
return HttpResponse(
f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
)
@login_required
@user_is_atlas_editor
def task_overview(request):
"""Atlas editor task control page for inspecting and recovering queued/background tasks."""
if DBTaskResult is None:
return render(
request,
"atlas/task_overview.html",
{
"tasks": [],
"counts": {"active": 0, "completed": 0, "failed": 0},
"backend_missing": True,
},
)
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]
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()}")
selected_qs = DBTaskResult.objects.filter(id__in=selected_ids)
if action == "reset_failed":
updated = selected_qs.filter(status="FAILED").update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
messages.success(request, f"Reset {updated} failed task(s) to READY.")
elif action == "reset_selected":
blocked = selected_qs.filter(status="RUNNING").count()
updated = selected_qs.exclude(status="RUNNING").update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
msg = f"Reset {updated} task(s) to READY."
if blocked:
msg += f" Skipped {blocked} running task(s)."
messages.success(request, msg)
elif action == "reset_stale_running":
stale_minutes_raw = (request.POST.get("stale_minutes") or "30").strip()
try:
stale_minutes = max(int(stale_minutes_raw), 1)
except ValueError:
stale_minutes = 30
cutoff = timezone.now() - timedelta(minutes=stale_minutes)
updated = selected_qs.filter(status="RUNNING", started_at__lt=cutoff).update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
messages.success(
request,
f"Reset {updated} stale RUNNING task(s) to READY (older than {stale_minutes} minute(s)).",
)
elif action == "delete_completed":
deleted, _ = selected_qs.filter(status="SUCCESSFUL").delete()
messages.success(request, f"Deleted {deleted} completed task record(s).")
elif action == "delete_failed":
deleted, _ = selected_qs.filter(status="FAILED").delete()
messages.success(request, f"Deleted {deleted} failed task record(s).")
elif action == "delete_selected_all":
deleted, _ = selected_qs.delete()
messages.success(request, f"Deleted {deleted} selected task record(s).")
else:
messages.error(request, "Invalid task action requested.")
status_q = (request.GET.get("status") or "all").strip().lower()
return redirect(f"{reverse('atlas:task_overview')}?status={status_q}")
selected_status = (request.GET.get("status") or "all").strip().lower()
status_map = {
"active": ["READY", "RUNNING"],
"completed": ["SUCCESSFUL"],
"failed": ["FAILED"],
}
base_qs = DBTaskResult.objects.all().order_by("-enqueued_at")
if selected_status in status_map:
tasks = list(base_qs.filter(status__in=status_map[selected_status])[:300])
else:
tasks = list(base_qs[:300])
for task in tasks:
generated_series = []
payload = task.return_value
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = None
if isinstance(payload, dict):
for item in payload.get("created_series", []) or []:
if not isinstance(item, dict):
continue
url = item.get("url")
description = item.get("description") or str(item.get("id", "Series"))
if url:
generated_series.append({"url": url, "description": description})
task.generated_series = generated_series
counts = {
"active": DBTaskResult.objects.filter(status__in=["READY", "RUNNING"]).count(),
"completed": DBTaskResult.objects.filter(status="SUCCESSFUL").count(),
"failed": DBTaskResult.objects.filter(status="FAILED").count(),
}
return render(
request,
"atlas/task_overview.html",
{
"tasks": tasks,
"counts": counts,
"selected_status": selected_status,
"backend_missing": False,
},
)
@login_required
def series_image_size_htmx(request, series_id):
"""HTMX endpoint returning the total size of images in a series.
If `?refresh=1` is provided the size will be recalculated.
"""
series = get_object_or_404(Series, pk=series_id)
# Permission: reuse existing series permission check if available
try:
can_view = series.check_user_can_view(request.user)
except Exception:
# fallback to basic permission: allow authenticated users
can_view = True
if not can_view:
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
refresh = request.GET.get("refresh") in ("1", "true", "True")
try:
size_bytes = series.get_total_image_size(refresh=refresh)
except Exception:
size_bytes = 0
human = filesizeformat(size_bytes)
html = render_to_string(
"atlas/partials/_series_image_size.html",
{"series": series, "size_bytes": size_bytes, "size_human": human, "refresh": refresh},
request=request,
)
return HttpResponse(html)
@login_required
def case_search_partial(request):
"""Return an HTMX partial listing cases matching the query parameter `q`.
This view returns a small list-group fragment that can be swapped into
the index page. It respects the user's available cases via
get_cases_available_to_user.
"""
# NOTE (search migration):
#
# This view currently performs simple `__icontains` lookups across Case
# fields and related models. For better relevance and performance you can
# migrate this to use PostgreSQL full-text search (FTS). Recommended steps:
#
# 1) Enable optional Postgres extensions (if you want trigram/unaccent):
# - Add migrations: `TrigramExtension()` and optionally `UnaccentExtension()`
# from `django.contrib.postgres.operations`.
#
# 2) Create a tsvector expression index (recommended) or add a
# SearchVectorField on the Case model. Example expression index SQL:
# CREATE INDEX CONCURRENTLY idx_case_search ON atlas_case
# USING GIN (to_tsvector('english', coalesce(title,'') || ' ' ||
# coalesce(description,'') || ' ' || coalesce(history,'')));
#
# Alternatively use a Django migration with `GinIndex` on an
# `SearchVectorField` or a `Func` expression.
#
# 3) Query using django.contrib.postgres.search utilities. Example:
# from django.contrib.postgres.search import (
# SearchVector, SearchQuery, SearchRank
# )
# sv = (SearchVector('title', weight='A') +
# SearchVector('description', weight='B') +
# SearchVector('history', weight='C'))
# sq = SearchQuery(q)
# qs = Case.objects.annotate(search=sv).filter(search=sq)
# .annotate(rank=SearchRank(F('search'), sq)).order_by('-rank')
#
# 4) For interactive/fuzzy typing consider pg_trgm (TrigramExtension)
# and `TrigramSimilarity` as a fallback when FTS returns few results.
#
# 5) Keep in mind to re-index (or recreate indexes) when you change
# the fields included in the tsvector. Use concurrent index creation
# for minimal downtime.
#
# 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 = q.strip()
# Start with cases available to the user
qs = get_cases_available_to_user(request.user)
if q:
from django.db.models import Q
# Search across multiple Case fields and related names (conditions,
# presentations, subspecialties, series fields and authors).
qs = qs.filter(
Q(title__icontains=q)
| Q(description__icontains=q)
| Q(history__icontains=q)
| Q(discussion__icontains=q)
| Q(report__icontains=q)
| Q(condition__name__icontains=q)
| Q(presentation__name__icontains=q)
| Q(subspecialty__name__icontains=q)
| Q(series__description__icontains=q)
# Series.modality is a FK to generic.Modality; search its text field
| Q(series__modality__modality__icontains=q)
| Q(series__examination__examination__icontains=q)
| Q(author__first_name__icontains=q)
| Q(author__last_name__icontains=q)
| Q(author__username__icontains=q)
).distinct()
qs = qs.order_by('-created_date')[:20]
html = render_to_string('atlas/partials/_case_search_results.html', {'cases': qs}, request=request)
return HttpResponse(html)
@login_required
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')
# Use SeriesFinding objects for the widget (series-scoped findings)
qs = SeriesFinding.objects.select_related('series').prefetch_related('findings')
if case_id:
try:
cid = int(case_id)
# Series are linked to cases through SeriesDetail; filter SeriesFinding by linked series' case
qs = qs.filter(series__seriesdetail__case__pk=cid)
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)
return HttpResponse(html)
@login_required
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')
qs = CaseDisplaySet.objects.all()
if case_id:
try:
cid = int(case_id)
qs = qs.filter(case__pk=cid)
except Exception:
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)
return HttpResponse(html)
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
if (
self.request.user.groups.filter(name="atlas_editor").exists()
or self.request.user.is_superuser
):
return obj
if self.request.user not in obj.get_author_objects():
raise PermissionDenied() # or Http404
return obj
class AtlasEditorRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
if (
self.request.user.groups.filter(name="atlas_editor").exists()
or self.request.user.is_superuser
):
return obj
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"
filterset_class = NormalCaseFilter
paginate_by = 25
SORT_CHOICES = {
"newest": "-added_date",
"oldest": "added_date",
"age_asc": "age_days",
"age_desc": "-age_days",
"title_asc": "case__title",
"title_desc": "-case__title",
}
def get_selected_sort(self):
sort_key = self.request.GET.get("sort", "newest")
if sort_key not in self.SORT_CHOICES:
return "newest"
return sort_key
def get_queryset(self):
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)))
order_by = self.SORT_CHOICES[self.get_selected_sort()]
return qs.order_by(order_by)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["selected_sort"] = self.get_selected_sort()
context["sort_choices"] = [
("newest", "Newest added"),
("oldest", "Oldest added"),
("age_asc", "Age (youngest first)"),
("age_desc", "Age (oldest first)"),
("title_asc", "Title (AZ)"),
("title_desc", "Title (ZA)"),
]
params = self.request.GET.copy()
if "page" in params:
params.pop("page")
context["querystring_without_page"] = params.urlencode()
params_without_sort = params.copy()
if "sort" in params_without_sort:
params_without_sort.pop("sort")
context["querystring_without_page_sort"] = params_without_sort.urlencode()
return context
@login_required
@user_has_case_view_access
def case_displaysets(request, pk):
"""Render the dedicated case display set workspace.
This is the full-page management/viewer screen used to review all display sets
for a case and, when permitted, add/edit/delete them inline via HTMX.
Query params:
- ``displayset``: highlight/select a specific display set row.
- ``edit``: when truthy and ``displayset`` is provided, auto-open that row's
inline edit form on page load.
"""
case = get_object_or_404(Case, pk=pk)
can_edit = case.check_user_can_edit(request.user)
displayset_id = request.GET.get("displayset")
edit = request.GET.get("edit", False)
selected_displayset = None
if displayset_id:
try:
selected_displayset = case.display_sets.get(pk=displayset_id)
except CaseDisplaySet.DoesNotExist:
selected_displayset = None
return render(
request,
"atlas/case_displaysets.html",
{
"case": case,
"edit": edit,
"can_edit": can_edit,
"selected_displayset": selected_displayset,
},
)
@login_required
def case_search_page(request):
"""Full page case search with quick filters.
Supports GET params:
- q: query string
- field: one of ['all','title','description','history','author']
- my: '1' to limit to cases authored by current user
- open: '1' to limit to open_access cases
- has_images: '1' to require series images
- subspecialty: id of subspecialty to filter
- page: pagination page
"""
q = request.GET.get("q", "").strip()
field = request.GET.get("field", "all")
my_cases = request.GET.get("my", "0") == "1"
open_access = request.GET.get("open", "0") == "1"
has_images = request.GET.get("has_images", "0") == "1"
subspecialty_id = request.GET.get("subspecialty")
qs = get_cases_available_to_user(request.user)
if my_cases:
qs = qs.filter(author=request.user)
if open_access:
qs = qs.filter(open_access=True)
if subspecialty_id:
try:
sid = int(subspecialty_id)
qs = qs.filter(subspecialty__id=sid)
except (ValueError, TypeError):
pass
if q:
if field == "title":
qs = qs.filter(title__icontains=q)
elif field == "description":
qs = qs.filter(description__icontains=q)
elif field == "history":
qs = qs.filter(history__icontains=q)
elif field == "author":
qs = qs.filter(
Q(author__first_name__icontains=q)
| Q(author__last_name__icontains=q)
| Q(author__username__icontains=q)
)
else:
qs = qs.filter(
Q(title__icontains=q)
| Q(description__icontains=q)
| Q(history__icontains=q)
| Q(discussion__icontains=q)
| Q(report__icontains=q)
| Q(condition__name__icontains=q)
| Q(presentation__name__icontains=q)
| Q(subspecialty__name__icontains=q)
| Q(series__description__icontains=q)
| Q(series__modality__modality__icontains=q)
| Q(series__examination__examination__icontains=q)
| Q(author__first_name__icontains=q)
| Q(author__last_name__icontains=q)
| Q(author__username__icontains=q)
).distinct()
if has_images:
qs = qs.filter(series__images__isnull=False).distinct()
qs = qs.order_by("-created_date")
# Paginate results
paginator = Paginator(qs, 25)
page_number = request.GET.get("page")
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]
subspecialty_queryset = Subspecialty.objects.all().order_by("name")
return render(
request,
"atlas/case_search_page.html",
{
"cases": page_obj,
"q": q,
"field": field,
"my": my_cases,
"open": open_access,
"has_images": has_images,
"subspecialty_queryset": subspecialty_queryset,
"selected_subspecialty": subspecialty_id,
"recent_cases": recent_cases,
},
)
@login_required
def api_tokens_page(request):
"""Simple UI to create and revoke API tokens for the logged-in user."""
created_token = None
if request.method == "POST":
action = request.POST.get("action")
if action == "create":
name = request.POST.get("name", "")
scopes = request.POST.get("scopes", "")
expires_days = request.POST.get("expires_days")
expires = None
if expires_days:
try:
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)
created_token = token
elif action == "revoke":
revoke_id = request.POST.get("revoke_id")
try:
t = APIToken.objects.get(pk=revoke_id, user=request.user)
t.revoked = True
t.save(update_fields=["revoked"])
except APIToken.DoesNotExist:
pass
tokens_qs = APIToken.objects.filter(user=request.user).order_by("-created")
tokens = [
{
"id": t.id,
"name": t.name,
"scopes": t.scopes,
"created": t.created,
"expires": t.expires,
"revoked": t.revoked,
}
for t in tokens_qs
]
return render(request, "atlas/api_tokens.html", {"tokens": tokens, "created_token": created_token})
@login_required
@user_has_case_view_access
@require_http_methods(["GET", "POST"])
def case_displaysets_add(request, pk):
"""Create a display set for a case and return HTMX fragments.
GET returns a blank display set form fragment.
POST validates and creates the display set, then returns the rendered list row.
"""
case = get_object_or_404(Case, pk=pk)
form = CaseDisplaySetForm(request.POST or None)
if request.method == "POST":
if form.is_valid():
ds = form.save(commit=False)
ds.case = case
ds.author = request.user
ds.viewerstate = request.POST.get("viewerstate") or None
ds.annotations = request.POST.get("annotations") or None
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})
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)
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)
return HttpResponse(html)
@login_required
@require_http_methods(["GET", "POST"])
def case_displaysets_edit(request, pk):
"""Edit a single display set via HTMX.
This endpoint is fragment-only and is intended to be called from the dedicated
``case_displaysets`` page inline editor, not linked as a standalone page.
"""
ds = get_object_or_404(CaseDisplaySet, pk=pk)
case = ds.case
if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit this display set.")
form = CaseDisplaySetForm(request.POST or None, instance=ds)
if request.method == "POST":
if form.is_valid():
ds = form.save(commit=False)
ds.viewerstate = request.POST.get("viewerstate") or ds.viewerstate
ds.annotations = request.POST.get("annotations") or ds.annotations
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)
return HttpResponse(html)
else:
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)
return HttpResponse(html)
@login_required
@require_http_methods(["GET"])
def case_displaysets_detail(request, pk):
"""Redirect to the case display set workspace focused on one display set.
Used as the canonical navigation target from non-management pages.
It redirects to ``case_displaysets`` with ``displayset=<pk>`` and forwards
optional ``edit`` intent (for example ``?edit=1``) so the target page can
auto-open inline editing for permitted users.
"""
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
case = displayset.case
edit = request.GET.get("edit")
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)
@login_required
def case_displaysets_modal(request, pk):
"""Return a display set detail fragment for modal/overlay viewers.
This supports pages that do not have the full ``case_displaysets`` workspace
open but still need to inspect a display set in-place.
"""
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
can_edit = False
try:
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})
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})
def case_displaysets_delete(request, pk):
"""Delete a display set and return a replacement row fragment/message for HTMX."""
try:
displayset = CaseDisplaySet.objects.get(pk=pk)
except CaseDisplaySet.DoesNotExist:
raise Http404("Display set not found.")
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("<li class='list-group-item' >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
.prefetch_related(
Prefetch("author", queryset=User.objects.all()),
Prefetch(
"seriesdetail_set",
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"
)
),
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")),
)
),
),
),
Prefetch("subspecialty", queryset=Subspecialty.objects.all()),
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("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()),
)),
)
.get(pk=pk)
)
case.ordered_series = [sd.series for sd in case.seriesdetail_set.all()]
return case
except Case.DoesNotExist:
raise Http404("Case not found.")
@login_required
@user_has_case_view_access
def case_detail(request, pk):
"""Render the Atlas case detail page with precomputed series and move targets."""
case = get_case_for_case_detail(pk)
can_edit = case.check_user_can_edit(request.user)
move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case)
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")
if prefetched_images is not None:
series.visible_image_count = len(prefetched_images)
else:
series.visible_image_count = series.images.filter(removed=False).count()
move_series_recent_cases = list(
get_cases_available_to_user(request.user)
.exclude(pk=case.pk)
.filter(author=request.user)
.order_by("-created_date")
.prefetch_related("author", "condition", "presentation", "subspecialty")[:5]
)
return render(
request,
"atlas/case_detail.html",
{
"case": case,
"cimar_sid": request.user.userprofile.cimar_sid,
"can_edit": can_edit,
"move_series_form": move_series_form,
"move_series_recent_cases": move_series_recent_cases,
"ordered_series": ordered_series,
# Provide helper lists for resource search filters
"site_queryset": Site.objects.all(),
"subspecialty_queryset": Subspecialty.objects.all(),
},
)
CASE_INLINE_FIELD_CONFIG = {
"title": {"label": "Title", "kind": "text"},
"description": {"label": "Description", "kind": "textarea"},
"history": {"label": "History", "kind": "textarea"},
"discussion": {"label": "Discussion", "kind": "textarea"},
"report": {"label": "Report", "kind": "textarea"},
"diagnostic_certainty": {"label": "Diagnostic certainty", "kind": "select"},
"subspecialty": {"label": "Subspecialty", "kind": "select"},
}
def _dedupe_non_empty(values):
out = []
seen = set()
for value in values:
text = (value or "").strip()
if not text or text in seen:
continue
seen.add(text)
out.append(text)
return out
def _get_case_inline_suggestions(case, field_name):
condition_names = [c.name for c in case.condition.all()[:3]]
presentation_names = [p.name for p in case.presentation.all()[:3]]
procedure_names = [p.name for p in case.procedures.all()[:3]]
conditions = ", ".join(condition_names)
presentations = ", ".join(presentation_names)
procedures = ", ".join(procedure_names)
first_series = None
try:
ordered = case.get_ordered_series()
if ordered:
first_series = ordered[0]
except Exception:
first_series = None
modality_text = ""
examination_text = ""
if first_series is not None:
try:
if first_series.modality is not None:
modality_text = str(first_series.modality)
except Exception:
modality_text = ""
try:
if first_series.examination is not None:
examination_text = str(first_series.examination)
except Exception:
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]
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]
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]
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]
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 []
def _validate_case_inline_value(field_name, raw_value):
if field_name == "diagnostic_certainty":
try:
parsed = int(raw_value)
except Exception:
return None, "Please choose a valid diagnostic certainty option."
allowed = {choice.value for choice in Case.CertaintyChoices}
if parsed not in allowed:
return None, "Please choose a valid diagnostic certainty option."
return parsed, None
value = (raw_value or "").strip()
if field_name == "title":
if not value:
return None, "Title cannot be empty."
if len(value) > 255:
return None, "Title must be 255 characters or fewer."
return value, None
@login_required
@user_has_case_view_access
@require_http_methods(["GET", "POST"])
def case_inline_field(request, pk, field_name):
case = get_case_for_case_detail(pk)
field_cfg = CASE_INLINE_FIELD_CONFIG.get(field_name)
if field_cfg is None:
raise Http404("Unknown inline-edit field.")
can_edit = case.check_user_can_edit(request.user)
editing = request.GET.get("edit") == "1"
show_label = request.GET.get("show_label", "1") not in ("0", "false", "False")
error_message = ""
# Special-case handling for many-to-many `subspecialty` field
if field_name == "subspecialty":
if request.method == "POST":
if not can_edit:
return HttpResponse(status=403)
editing = True
# Accept multiple values via POST list
posted = request.POST.getlist("value")
ids = []
for v in posted:
try:
ids.append(int(v))
except Exception:
continue
# keep only valid existing ids
valid_ids = list(
Subspecialty.objects.filter(pk__in=ids).values_list("pk", flat=True)
)
case.subspecialty.set(valid_ids)
editing = False
value = case.subspecialty.all()
else:
value = case.subspecialty.all()
if editing and not can_edit:
editing = False
else:
if request.method == "POST":
if not can_edit:
return HttpResponse(status=403)
editing = True
cleaned_value, error_message = _validate_case_inline_value(
field_name=field_name,
raw_value=request.POST.get("value", ""),
)
if error_message:
value = request.POST.get("value", "")
else:
setattr(case, field_name, cleaned_value)
case.save(update_fields=[field_name])
editing = False
value = getattr(case, field_name)
else:
value = getattr(case, field_name)
if editing and not can_edit:
editing = False
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]
selected_values = []
multiple = False
elif field_name == "subspecialty":
# For many-to-many, provide choices and selected values
display_value = ", ".join([str(s) for s in case.subspecialty.all()])
select_choices = [(str(s.pk), str(s.name)) for s in Subspecialty.objects.all()]
selected_values = [str(s.pk) for s in case.subspecialty.all()]
multiple = True
else:
display_value = value
select_choices = []
selected_values = []
multiple = False
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"
else:
endpoint_url_default = f"{endpoint_url}?show_label=0"
endpoint_url_edit = f"{endpoint_url}?edit=1&show_label=0"
html = render_to_string(
"atlas/partials/case_inline_field.html",
{
"case": case,
"field_name": field_name,
"field_label": field_cfg["label"],
"field_kind": field_cfg["kind"],
"value": value,
"display_value": display_value,
"editing": editing,
"show_label": show_label,
"can_edit": can_edit,
"error_message": error_message,
"suggestions": _get_case_inline_suggestions(case, field_name),
"select_choices": select_choices,
"selected_values": selected_values,
"multiple": multiple,
"endpoint_url_default": endpoint_url_default,
"endpoint_url_edit": endpoint_url_edit,
"container_id": f"case-inline-{field_name}",
},
request=request,
)
return HttpResponse(html)
@login_required
@require_POST
def toggle_case_normal(request, pk):
"""HTMX endpoint to toggle the NormalCase mark for a Case.
- Only users in the 'atlas_editor' group or superusers may toggle.
- On create, attempt to auto-extract age_years from DICOM metadata.
- Returns an HTML fragment that replaces the toggle block.
"""
case = get_case_for_case_detail(pk)
# Permission: rely on `can_edit` check on the Case model
can_edit = case.check_user_can_edit(request.user)
if not can_edit:
return HttpResponse(status=403)
# If already marked normal, unmark (delete the NormalCase)
try:
normal = case.normal_case
except Exception:
normal = None
if normal is not None:
normal.delete()
# Remove any cached relation on the case and reload so the template
# sees the updated state (no normal_case).
try:
if hasattr(case, "normal_case"):
delattr(case, "normal_case")
except Exception:
pass
case = get_case_for_case_detail(pk)
else:
# Attempt to auto-extract age from DICOM
age = None
try:
from datetime import datetime
import re
# Iterate series then images to find first usable DICOM
for series in case.series.all():
img = series.images.filter(removed=False).first()
if not img:
continue
ds = img.get_dicom_data()
if not ds:
continue
# Try PatientAge (format often like '045Y' or '45Y')
try:
pa = getattr(ds, "PatientAge", None)
except Exception:
pa = None
if pa:
s = str(pa)
m = re.search(r"(\d+)", s)
if m:
age = int(m.group(1))
break
# Try birthdate + studydate
try:
pbd = getattr(ds, "PatientBirthDate", None)
sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None)
except Exception:
pbd = None
sdate = None
if pbd and sdate:
try:
bd = datetime.strptime(str(pbd), "%Y%m%d")
sd = datetime.strptime(str(sdate), "%Y%m%d")
years = round((sd - bd).days / 365.25)
age = int(years)
break
except Exception:
pass
except Exception:
age = None
# Create NormalCase
from .models import NormalCase
# Convert inferred integer years -> canonical days (approx)
if age is not None:
age_days = NormalCase.to_days(age, NormalCase.AGE_UNIT_YEARS)
else:
age_days = None
NormalCase.objects.create(case=case, age_days=age_days, added_by=request.user)
# 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_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},
request=request,
)
return HttpResponse(html)
@login_required
def case_normal_form(request, pk):
"""Return an HTMX modal with a NormalCaseForm prepopulated with auto-extracted values."""
case = get_case_for_case_detail(pk)
# Permission check: rely on `can_edit` helper on the Case model
can_edit = case.check_user_can_edit(request.user)
if not can_edit:
return HttpResponse(status=403)
# Extract defaults
age = None
inferred_examination = None
inferred_modality = None
try:
for series in case.series.all():
img = series.images.filter(removed=False).first()
if not img:
continue
ds = img.get_dicom_data()
if not ds:
continue
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)
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")
years = round((sd - bd).days / 365.25)
age = int(years)
break
except Exception:
pass
# Try inferring exam/modality from first series
first_series = case.series.first()
if first_series is not None:
inferred_examination = getattr(first_series, "examination", None)
inferred_modality = getattr(first_series, "modality", None)
except Exception:
pass
# Build form with initial values
from .forms import NormalCaseForm
# 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:
if age is not None:
# age currently holds integer years; for small ages prefer months
if age < 2:
inferred_age_value = int(age * 12)
inferred_age_unit = NormalCase.AGE_UNIT_MONTHS
else:
inferred_age_value = int(age)
inferred_age_unit = NormalCase.AGE_UNIT_YEARS
except Exception:
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,
})
# 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)
logger.debug("case_normal_form returning HTML length: {}", len(html))
return HttpResponse(html)
@login_required
@require_POST
def create_case_normal(request, pk):
case = get_case_for_case_detail(pk)
# Permission check: rely on `can_edit` helper on the Case model
can_edit = case.check_user_can_edit(request.user)
if not can_edit:
return HttpResponse(status=403)
from .forms import NormalCaseForm
from loguru import logger
form = NormalCaseForm(request.POST)
logger.debug("create_case_normal POST data: {}", dict(request.POST))
if form.is_valid():
logger.debug("NormalCaseForm.cleaned_data: {}", form.cleaned_data)
nc = form.save(commit=False)
nc.case = case
# Save instance before modifying many-to-many relations
nc.save()
form.save_m2m()
nc.author.add(request.user)
# Ensure the case reflects the newly created NormalCase when rendering
try:
if hasattr(case, "normal_case"):
delattr(case, "normal_case")
except Exception:
pass
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_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},
request=request,
)
toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".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)
return HttpResponse(html, status=400)
@login_required
@require_http_methods(["GET", "POST"])
def case_normal_edit(request, pk):
"""Edit an existing NormalCase. Accessible to NormalCase authors and atlas editors."""
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_normal_author = normal.author.filter(id=request.user.id).exists()
if not (is_atlas_editor or is_normal_author):
return HttpResponse(status=403)
from .forms import NormalCaseForm
if request.method == "POST":
form = NormalCaseForm(request.POST, instance=normal)
if form.is_valid():
nc = form.save(commit=False)
nc.save()
form.save_m2m()
# Ensure the case reflects any updates
try:
if hasattr(nc.case, "normal_case"):
delattr(nc.case, "normal_case")
except Exception:
pass
case = get_case_for_case_detail(nc.case.pk)
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},
request=request,
)
toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".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)
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)
return HttpResponse(html)
@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail_fail(request, pk):
return series_thumbnail(request, pk=pk, fail_loudly=True)
@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail(request, pk, fail_loudly=False):
series = get_object_or_404(Series, pk=pk)
thumbnail, _ = series.get_thumbnail(recreate=True, fail_loudly=fail_loudly)
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,
}
return render(
request,
"atlas/series_viewer_test.html",
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()
]
detected_series_groups = _build_declared_series_groups(series, series_images_with_urls)
can_edit = series.check_user_can_edit(request.user)
editing_finding = -1
if finding_pk is not None:
finding = get_object_or_404(SeriesFinding, pk=finding_pk)
series_finding_form = SeriesFindingForm(None, instance=finding)
editing_finding = finding.pk
else:
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,
}
if can_edit:
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(
request,
"atlas/series.html",
context,
)
@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_images_partial(request, pk):
"""Return a small fragment that lists images for a series. Intended for HTMX."""
series = get_object_or_404(Series, pk=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})
@login_required
# @user_is_atlas_editor
def question_schema_detail(request, pk: int):
question_schema = get_object_or_404(QuestionSchema, pk=pk)
example_form = question_schema.get_example_form()
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"atlas/question_schema_detail.html",
{"question_schema": question_schema, "example_form": example_form},
)
@login_required
# @user_is_atlas_editor
def question_schema_schemas(request):
question_schemas = QuestionSchema.objects.all()
return render(
request,
"atlas/question_schemas_preset.html",
{
"question_schemas": question_schemas,
},
)
@login_required
# @user_is_atlas_editor
def condition_detail(request, pk):
condition = get_object_or_404(Condition, pk=pk)
# form used for the merge UI
merge_form = ConditionAutocompleteForm()
# form used to add a synonym (can post either an existing condition via autocomplete
# or provide a plain name in 'synonym_name')
synonym_form = ConditionAutocompleteForm()
relationship_form = ConditionRelationshipForm(instance=condition)
can_merge = False
if (
request.user.is_superuser
or request.user.groups.filter(name="atlas_editor").exists()
):
can_merge = True
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
)
if relationship_form.is_valid():
relationship_form.save()
if request.htmx:
html = render_to_string(
"atlas/partials/_condition_relationships.html",
{
"condition": condition,
"relationship_form": relationship_form,
},
request=request,
)
return HttpResponse(html)
return redirect("atlas:condition_detail", pk=condition.pk)
# Handle POST to add a synonym
if request.method == "POST" and request.POST.get("action") == "add_synonym":
# only allow staff/editors
if not (
request.user.is_superuser
or request.user.groups.filter(name="atlas_editor").exists()
):
return HttpResponse("Forbidden", status=403)
# Try autocomplete-selected condition first
form = ConditionAutocompleteForm(request.POST)
other = None
if form.is_valid():
other = form.cleaned_data.get("condition")
# keep the form (possibly with errors) to render back in HTMX responses
synonym_form = form
# If no autocomplete selection, accept a free-text name
synonym_name = request.POST.get("synonym_name")
if not other and synonym_name:
other, created = Condition.objects.get_or_create(name=synonym_name)
if created:
# created condition: treat as alias by setting canonical below
pass
if other and other != condition:
# Option B: treat synonym as alias/canonical relationship.
# Determine canonical/master for the current condition
master = condition.canonical if condition.canonical else condition
# If `other` belongs to a different canonical group, move its group
# 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()
else:
# other is standalone; make it an alias of our master
other.canonical = master
other.save()
# Master is indicated by having no `canonical` pointer. Ensure
# it remains canonical (no save required unless changed elsewhere).
# If this was an HTMX request return the updated synonyms partial so the
# page can be updated without a full redirect. Otherwise perform the
# existing redirect back to the detail page.
if request.htmx:
html = render_to_string(
"atlas/partials/_synonyms_list.html",
{"condition": condition, "synonym_form": synonym_form, "can_merge": can_merge},
request=request,
)
return HttpResponse(html)
return redirect("atlas:condition_detail", pk=condition.pk)
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"atlas/condition_detail.html",
{
"condition": condition,
"form": merge_form,
"synonym_form": synonym_form,
"relationship_form": relationship_form,
"can_merge": can_merge,
},
)
# class GetConditionAutocomplete(HTMXAutoComplete):
# name = "conditions"
# multiselect = False
#
# class Meta:
# model = Condition
@login_required
@user_is_atlas_editor
def condition_merge(request, pk_to_merge):
if not request.htmx:
raise Http404
condition_to_merge = get_object_or_404(Condition, pk=pk_to_merge)
# condition_to_merge_into = get_object_or_404(Condition, pk=pk_to_merge_into)
logger.debug("condition_merge: case_set={}", condition_to_merge.case_set)
if condition_to_merge.rcr_curriculum:
return HttpResponse("You cannot merge rcr curriculum items")
condition_to_merge_into = request.POST.get("conditions")
if not condition_to_merge_into:
return HttpResponse("You need to select a condition to merge into.")
condition_to_merge_into = get_object_or_404(Condition, pk=condition_to_merge_into)
if condition_to_merge == condition_to_merge_into:
return HttpResponse("You cannot merge into the same condition.")
# move all relevant relationships
cases = condition_to_merge.case_set.all().values_list("id", flat=True)
condition_to_merge_into.case_set.add(cases)
# cases = Case.objects.filter(condition=condition_to_merge)
# cases.update(condition=condition_to_merge_into)
differentials = Differential.objects.filter(condition=condition_to_merge)
differentials.update(condition=condition_to_merge_into)
return HttpResponse("Merged")
# conditions = Condition.objects.all()
# return render(request, "atlas/condition_options.html", {"conditions": conditions})
@login_required
# @user_is_atlas_editor
def subspecialty_detail(request, pk):
subspecialty = get_object_or_404(Subspecialty, pk=pk)
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"atlas/subspecialty_detail.html",
{
"subspecialty": subspecialty,
},
)
@login_required
# @user_is_atlas_editor
def presentation_detail(request, pk):
presentation = get_object_or_404(Presentation, pk=pk)
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"atlas/presentation_detail.html",
{
"presentation": presentation,
},
)
@login_required
def procedure_detail(request, pk):
procedure = get_object_or_404(Procedure, pk=pk)
# Cases that have this procedure
cases = Case.objects.filter(procedures__id=procedure.pk)
return render(
request,
"atlas/procedure_detail.html",
{
"procedure": procedure,
"cases": cases,
},
)
@login_required
# @user_is_atlas_editor
def pathological_process_detail(request, pk):
pathological_process = get_object_or_404(PathologicalProcess, pk=pk)
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"atlas/pathological_process_detail.html",
{
"pathological_process": pathological_process,
},
)
@login_required
# @user_is_atlas_editor
def structure_detail(request, pk):
structure = get_object_or_404(Structure, pk=pk)
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"atlas/structure_detail.html",
{
"structure": structure,
},
)
@login_required
# @user_is_atlas_editor
def finding_detail(request, pk):
finding = get_object_or_404(Finding, pk=pk)
# logging.debug(atlas.subspecialty.first().name.all())
return render(
request,
"atlas/finding_detail.html",
{
"finding": finding,
},
)
@login_required
def resource_detail(request, pk):
resource = get_object_or_404(Resource, pk=pk)
return render(request, "atlas/resource_detail.html", {"resource": resource})
@login_required
def resource_view(request, pk):
resource = get_object_or_404(Resource, pk=pk)
return HttpResponse(resource.get_display())
@login_required
def author_detail(request, pk):
# logging.debug(Author.objects.all())
# author = get_object_or_404(Author, pk=pk)
author = User.objects.get(pk=pk)
if request.user.pk == pk:
atlas = Case.objects.filter(author=pk)
else:
atlas = Case.objects.filter(author=pk, open_access=True)
return render(
request, "atlas/category_detail.html", {"category": author, "atlas": atlas}
)
@user_is_author_or_atlas_editor
def author_list(request):
authors = User.objects.all()
return render(request, "atlas/author_list.html", {"authors": authors})
@login_required
def index(request):
return render(request, "atlas/index.html", {})
@login_required
def user_collections(request):
"""Return collection buckets for a user (available/in-progress/finished).
By default this uses ``request.user``. Superusers may optionally provide
``?user_id=<id>`` to inspect another user's collection state. HTMX requests
return the ``atlas/partials/_user_collections.html`` fragment; normal
requests render ``atlas/user_collections.html``.
"""
target_user = request.user
requested_user_id = request.GET.get("user_id")
if requested_user_id and request.user.is_superuser:
try:
target_user = User.objects.get(pk=int(requested_user_id))
except (User.DoesNotExist, ValueError, TypeError):
target_user = request.user
# Collections the user is explicitly allowed to take (via CaseCollection.valid_user_users)
available_collections = target_user.user_casecollection_exams.all()
# Collections the user has already taken (recorded in generic.CidUserExam)
in_progress_ids = []
finished_ids = []
try:
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()
)
finished_ids = list(
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
in_progress_ids = []
finished_ids = []
# Build querysets for the three groups
taken_all_ids = set(in_progress_ids) | set(finished_ids)
# Available to start: explicitly allowed collections that the user hasn't taken at all
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")
# Finished (completed)
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)
)
if not show_hidden:
available_qs = available_qs.exclude(pk__in=hidden_ids)
in_progress_qs = in_progress_qs.exclude(pk__in=hidden_ids)
finished_qs = finished_qs.exclude(pk__in=hidden_ids)
available_list = list(available_qs)
in_progress_list = list(in_progress_qs)
finished_list = list(finished_qs)
for coll in available_list + in_progress_list + finished_list:
coll.is_hidden = coll.pk in hidden_ids
context = {
"target_user": target_user,
"available_collections": available_list,
"in_progress_collections": in_progress_list,
"finished_collections": finished_list,
"show_hidden": show_hidden,
}
# 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"
if is_htmx:
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.
@login_required
def user_related_collections(request):
"""Return up to 10 most recently modified collections where the user
is an author or a marker. If called via HTMX return a fragment suitable
for swapping into the index page.
"""
user = request.user
qs = (
CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
.distinct()
.order_by("-updated")[:10]
)
related = []
for c in qs:
related.append(
{
"collection": c,
"is_author": c.author.filter(id=user.id).exists(),
"is_marker": c.markers.filter(id=user.id).exists(),
}
)
context = {"related_collections": related}
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)
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)
return HttpResponse(html)
@login_required
def collection_case_jump_partial(request, pk: int):
"""Return an HTMX partial containing a jump list for cases in the collection.
Expects optional GET params:
- current: the current case index (0-based) to mark active
- cid, passcode: optional candidate identifiers so links include them when present
"""
# Allow non-HTMX callers to still get the fragment (useful for testing)
collection = get_object_or_404(CaseCollection, pk=pk)
casedetails = (
CaseDetail.objects.filter(collection=collection)
.select_related('case')
.order_by('sort_order')
)
try:
current = int(request.GET.get('current', 0) or 0)
except Exception:
current = 0
cid = request.GET.get('cid')
passcode = request.GET.get('passcode')
try:
cid_value = int(cid) if cid is not None else None
except Exception:
cid_value = None
cid_user_exam = collection.get_or_create_cid_user_exam(
cid=cid_value,
user_user=request.user,
)
if cid_value is not None:
answers = CidReportAnswer.objects.filter(cid=cid_value, question__in=casedetails)
else:
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
}
latest_reviews = (
cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails])
.select_related("case")
.order_by("case_id", "-review_update_date")
)
latest_review_by_case = {}
for review in latest_reviews:
if review.case_id not in latest_review_by_case:
latest_review_by_case[review.case_id] = review
jump_items = []
for idx, cd in enumerate(casedetails):
if cid is not None:
case_url = reverse(
"atlas:collection_case_view_take",
kwargs={"pk": collection.pk, "case_number": idx, "cid": cid, "passcode": passcode},
)
else:
case_url = reverse(
"atlas:collection_case_view_take_user",
kwargs={"pk": collection.pk, "case_number": idx},
)
add_url = reverse(
"atlas:add_self_review",
kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id},
)
review = latest_review_by_case.get(cd.case_id)
timing_state = _build_case_timing_state(
collection,
cd,
answer_by_case_id.get(cd.case_id),
)
review_view_url = None
review_edit_url = None
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},
)
review_edit_url = reverse(
"atlas:self_review_update",
kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id, "pk": review.id},
)
jump_items.append(
{
"idx": idx,
"cd": cd,
"case_url": case_url,
"self_review_add_url": add_url,
"self_review_view_url": review_view_url,
"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"],
}
)
html = render_to_string(
'atlas/partials/_collection_case_jump.html',
{
'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')
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.")
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)")
@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')
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.")
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)")
@login_required
def set_open_access(request):
if not request.htmx:
return Http404
case_ids = request.POST.getlist("selection")
if not case_ids:
return HttpResponse("No case ID(s) provided.")
cases = Case.objects.filter(pk__in=case_ids)
for case in cases:
# 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.")
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).")
@login_required
def remove_open_access(request):
if not request.htmx:
return Http404
case_ids = request.POST.getlist("selection")
if not case_ids:
return HttpResponse("No case ID(s) provided.")
cases = Case.objects.filter(pk__in=case_ids)
for case in cases:
# 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}.")
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).")
def add_case_to_collection(request, collection_id):
if not request.htmx:
return Http404
collection = get_object_or_404(CaseCollection, pk=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.")
if case in collection.cases.all():
return HttpResponse("Case already in collection")
logger.debug("collection_add_case: case={}", case)
collection.add_case(case)
# Render the new case list item and return it so HTMX can append it
try:
casedetail = collection.casedetail_set.get(case=case)
except Exception:
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()
rendered = render_to_string(
"atlas/partials/case_list_item.html",
{
"casedetail": casedetail,
"collection": collection,
"forloop": {"counter": total, "counter0": max(0, total - 1)},
"can_edit": can_edit,
},
request=request,
)
# Also instruct the client to remove this case from any visible search/recent lists
remove_script = (
'<script>'
'(function(){'
'try {'
"var pk = '%s';" % case.pk +
"document.querySelectorAll('#case-search-results [data-case-pk=\"' + pk + '\"]').forEach(function(el){ el.remove(); });" +
"document.querySelectorAll('#case-search-results input[name=\"case\"][value=\"' + pk + '\"]').forEach(function(inp){ var li = inp.closest('.list-group-item'); if (li) li.remove(); });" +
'} catch(e) { console.error("remove added case from search error", e); }'
'})();'
'</script>'
)
return HttpResponse(rendered + remove_script)
# if request.method == "POST":
# don't show search results on initial load; the search partial will render only when a query exists
cases = Case.objects.none()
# Also compute recent cases for initial display (authored by current user and not in collection)
recent_qs = get_cases_available_to_user(request.user).filter(author=request.user)
recent_qs = recent_qs.exclude(pk__in=collection.cases.values_list("pk", flat=True))
recent_cases = recent_qs.order_by("-created_date")[:5]
return render(
request,
"atlas/add_case_to_collection.html",
{"cases": cases, "collection": collection, "recent_cases": recent_cases, "q": ""},
)
@login_required
def case_search(request):
# HTMX endpoint for searching cases available to the user
if not request.htmx:
return Http404
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")
collection = None
if collection_id:
try:
collection = CaseCollection.objects.get(pk=collection_id)
except CaseCollection.DoesNotExist:
collection = None
qs = get_cases_available_to_user(request.user, collection=collection)
if q:
qs = qs.filter(title__icontains=q)
# Recent cases: last 5 created that are available to the user and not already in the collection
# 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_cases = recent_qs.order_by("-created_date")[:5]
# Exclude recent cases from the main search results to avoid duplication
recent_pks = [c.pk for c in recent_cases]
# Build the base queryset for results, apply excludes first (before slicing)
cases_qs = qs.exclude(pk__in=recent_pks)
# Also exclude any cases that are already present in the collection
if collection is not None:
existing_pks = list(collection.cases.values_list("pk", flat=True))
if existing_pks:
cases_qs = cases_qs.exclude(pk__in=existing_pks)
cases = cases_qs.order_by("title")[:30]
return render(
request,
"atlas/partials/case_search_results.html",
{
"cases": cases,
"collection": collection,
"recent_cases": recent_cases,
"q": q,
"show_select_button": show_select,
},
)
@login_required
def user_uploads_series(request, series_instance_uid: str):
# We don't restrict to teh uploading user here. Should we?
dicoms = UncategorisedDicom.objects.filter(series_instance_uid=series_instance_uid)
if not dicoms:
raise Http404("Series not found")
return render(
request,
"atlas/user_upload_viewer.html",
{"dicoms": dicoms, "series_id": series_instance_uid},
)
@login_required
def new_uploads(request):
return render(request, "atlas/new_uploads.html", {})
@login_required
@user_passes_test(lambda u: u.is_superuser)
def all_uploads(request, case_id: int | None = None):
return user_uploads(request, case_id, user_only=False)
@staff_member_required
def uploads_hash_search(request):
hash_query = (request.GET.get("hash") or "").strip().lower()
series_query = (request.GET.get("series") or "").strip()
study_query = (request.GET.get("study") or "").strip()
uploads_qs = UncategorisedDicom.objects.none()
imported_qs = SeriesImage.objects.none()
matched_series_qs = Series.objects.none()
has_query = bool(hash_query or series_query or study_query)
if has_query:
uploads_filters = Q()
imported_filters = Q()
matched_series_filters = Q()
if hash_query:
uploads_filters |= Q(image_blake3_hash__icontains=hash_query)
imported_filters |= Q(image_blake3_hash__icontains=hash_query)
if series_query:
uploads_filters |= Q(series_instance_uid__icontains=series_query)
imported_filters |= Q(series__series_instance_uid__icontains=series_query)
matched_series_filters |= Q(series_instance_uid__icontains=series_query)
if 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)
uploads_qs = (
UncategorisedDicom.objects.filter(uploads_filters)
.select_related("user", "series")
.order_by("-created_date")
.distinct()
)
imported_qs = (
SeriesImage.objects.filter(imported_filters)
.select_related("series")
.order_by("-pk")
.distinct()
)
if matched_series_filters.children:
matched_series_qs = (
Series.objects.filter(matched_series_filters)
.prefetch_related("case")
.order_by("-modified_date")
.distinct()
)
else:
matched_series_qs = Series.objects.none()
uploads_paginator = Paginator(uploads_qs, 50)
uploads_page_number = request.GET.get("uploads_page")
uploads_page_obj = uploads_paginator.get_page(uploads_page_number)
imported_paginator = Paginator(imported_qs, 50)
imported_page_number = request.GET.get("imported_page")
imported_page_obj = imported_paginator.get_page(imported_page_number)
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)
return render(
request,
"atlas/uploads_hash_search.html",
{
"hash_query": hash_query,
"series_query": series_query,
"study_query": study_query,
"uploads_page_obj": uploads_page_obj,
"uploads_result_count": uploads_paginator.count,
"imported_page_obj": imported_page_obj,
"imported_result_count": imported_paginator.count,
"matched_series_page_obj": matched_series_page_obj,
"matched_series_result_count": matched_series_paginator.count,
"series_study_scan_limit": 0,
},
)
@user_passes_test(lambda u: u.is_superuser)
def other_user_uploads(request, user_pk: int, case_id: int | None = None):
return user_uploads(request, case_id, user_only=False, user_pk=user_pk)
@login_required
def user_uploads(
request,
case_id: int | None = None,
user_only: bool = True,
user_pk: int | None = None,
):
"""List uploaded (unimported) DICOM series and suggest continuation imports.
Scope: Authenticated users (or superusers viewing other users' uploads).
Functionality:
- Groups uploaded series by study for easier selection.
- Detects when at least one uploaded series from a study is already imported
into a case.
- Exposes per-study suggestions to import only the remaining uploaded series
into that same case.
"""
user = request.user
if user_only:
dicoms = UncategorisedDicom.objects.filter(user=request.user)
elif user_pk is not None:
user = get_object_or_404(User, pk=user_pk)
dicoms = UncategorisedDicom.objects.filter(user=user)
else:
dicoms = UncategorisedDicom.objects.filter()
data = defaultdict(list)
series_hashes = defaultdict(set)
for d in dicoms:
tags = d.basic_dicom_tags
if tags is None:
d.save()
tags = d.basic_dicom_tags
series_uid = tags.get("SeriesInstanceUID")
if not series_uid:
continue
data[series_uid].append((tags, d.created_date))
if d.image_blake3_hash:
series_hashes[series_uid].add(d.image_blake3_hash)
series_list = []
for series in data:
tags, date = data[series][0]
series_list.append((series, len(data[series]), tags, date))
series_list.sort(key=lambda l: l[-1] or "")
# Group series by StudyInstanceUID for better visual grouping in the template
grouped = {}
for series_uid, count, tags, date in series_list:
study_uid = tags.get("StudyInstanceUID") or tags.get("StudyID") or "unknown"
study_desc = tags.get("StudyDescription", "")
# Normalise to a sortable string; DICOM StudyDate is "YYYYMMDD", fall
# back to the upload's created_date converted to the same format.
raw_study_date = tags.get("StudyDate")
if raw_study_date:
study_date = str(raw_study_date)
elif hasattr(date, "strftime"):
study_date = date.strftime("%Y%m%d")
else:
study_date = str(date)
if study_uid not in grouped:
grouped[study_uid] = {
"description": study_desc,
"date": study_date,
"series": [],
"import_suggestions": [],
"partial_imported_uids": [],
"matched_existing_series": [],
}
grouped[study_uid]["series"].append((series_uid, count, tags, date))
# Convert to a list preserving order sorted by group date
grouped_series = sorted(grouped.items(), key=lambda kv: kv[1].get("date") or "")
# Build per-study suggestions where some series are already in a case but
# additional uploaded series from the same study are still pending import.
uploaded_series_uids = [series_uid for series_uid, _, _, _ in series_list]
uploaded_study_uids = {
(tags.get("StudyInstanceUID") or tags.get("StudyID") or "")
for _, _, tags, _ in series_list
if (tags.get("StudyInstanceUID") or tags.get("StudyID"))
}
uploaded_series_uid_to_study_uid = {
series_uid: (tags.get("StudyInstanceUID") or tags.get("StudyID") or "")
for series_uid, _, tags, _ in series_list
}
uploaded_uid_to_existing_series_ids: dict[str, set[int]] = defaultdict(set)
uploaded_uid_to_case_ids: dict[str, set[int]] = defaultdict(set)
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
}
hash_to_uploaded_uids = defaultdict(set)
for uploaded_uid, hash_set in series_hashes.items():
for image_hash in hash_set:
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_study_series = Series.objects.filter(
study_instance_uid__in=uploaded_study_uids
).prefetch_related("case")
case_to_series_uids = defaultdict(set)
case_lookup = {}
for existing in existing_series:
if not existing.series_instance_uid:
continue
existing_series_lookup[existing.pk] = existing
uploaded_uid_to_existing_series_ids[existing.series_instance_uid].add(
existing.pk
)
for case_obj in existing.case.all():
if not case_obj.check_user_can_edit(request.user):
continue
case_lookup[case_obj.pk] = case_obj
case_to_series_uids[case_obj.pk].add(existing.series_instance_uid)
uploaded_uid_to_case_ids[existing.series_instance_uid].add(case_obj.pk)
if existing.study_instance_uid:
case_to_study_uids[case_obj.pk].add(existing.study_instance_uid)
for existing in existing_study_series:
if not existing.study_instance_uid:
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:
uploaded_uid_to_existing_series_ids[uploaded_uid].add(existing.pk)
for case_obj in existing.case.all():
if not case_obj.check_user_can_edit(request.user):
continue
case_lookup[case_obj.pk] = case_obj
case_to_study_uids[case_obj.pk].add(existing.study_instance_uid)
if uploaded_hashes:
imported_hash_matches = (
SeriesImage.objects.filter(image_blake3_hash__in=uploaded_hashes)
.exclude(series=None)
.select_related("series")
.prefetch_related("series__case")
)
for image in imported_hash_matches:
if image.series is None:
continue
existing_series_lookup[image.series.pk] = image.series
matched_uploaded_uids = hash_to_uploaded_uids.get(
image.image_blake3_hash, set()
)
for uploaded_uid in matched_uploaded_uids:
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
case_lookup[case_obj.pk] = case_obj
case_to_series_uids[case_obj.pk].add(uploaded_uid)
uploaded_uid_to_case_ids[uploaded_uid].add(case_obj.pk)
study_uid = uploaded_series_uid_to_study_uid.get(uploaded_uid)
if study_uid:
case_to_study_uids[case_obj.pk].add(study_uid)
for study_uid, study_data in grouped_series:
study_uploaded_uids = {
s_uid for s_uid, _, _, _ in study_data.get("series", [])
}
if not study_uploaded_uids:
continue
case_candidates_for_study = set()
study_partial_imported_uids = sorted(
s_uid
for s_uid in study_uploaded_uids
if uploaded_uid_to_existing_series_ids.get(s_uid)
)
if study_partial_imported_uids:
study_data["partial_imported_uids"] = study_partial_imported_uids
study_data["partial_imported_count"] = len(study_partial_imported_uids)
matched_series_ids = set()
for uploaded_uid in study_partial_imported_uids:
matched_series_ids.update(
uploaded_uid_to_existing_series_ids.get(uploaded_uid, set())
)
study_data["matched_existing_series"] = [
existing_series_lookup[series_id]
for series_id in sorted(matched_series_ids)
if series_id in existing_series_lookup
]
matched_case_counts = defaultdict(int)
for uploaded_uid in study_partial_imported_uids:
for case_pk in uploaded_uid_to_case_ids.get(uploaded_uid, set()):
matched_case_counts[case_pk] += 1
if matched_case_counts:
preferred_case_id, _ = max(
matched_case_counts.items(),
key=lambda item: (
item[1],
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())
for case_pk in related_case_ids:
imported_uids = case_to_series_uids.get(case_pk, set())
overlap = study_uploaded_uids & imported_uids
case_study_uids = case_to_study_uids.get(case_pk, set())
if not overlap and (not study_uid or study_uid not in case_study_uids):
continue
case_candidates_for_study.add(case_pk)
missing_uids = sorted(study_uploaded_uids - imported_uids)
if not missing_uids:
continue
study_data["import_suggestions"].append(
{
"case": case_lookup[case_pk],
"already_imported_count": len(overlap),
"missing_uids": missing_uids,
}
)
if study_data.get("preferred_case_id"):
pass
elif len(case_candidates_for_study) == 1:
study_data["preferred_case_id"] = next(iter(case_candidates_for_study))
elif study_data.get("import_suggestions"):
sorted_suggestions = sorted(
study_data["import_suggestions"],
key=lambda s: s.get("already_imported_count", 0),
reverse=True,
)
study_data["preferred_case_id"] = sorted_suggestions[0]["case"].pk
case = False
if case_id is not None:
case = get_object_or_404(Case, pk=case_id)
case_series_form = CaseForm(user=request.user)
case_series_form.fields["previous_case"].required = False
case_series_form.fields["previous_case"].widget = HiddenInput()
for field_name in ("condition", "presentation", "procedures"):
field = case_series_form.fields.get(field_name)
if field is None:
continue
field.widget = forms.SelectMultiple(
attrs={
"class": "form-select",
"size": 8,
}
)
field.widget.choices = field.choices
return render(
request,
"atlas/user_uploads.html",
{
"series_list": series_list,
"grouped_series": grouped_series,
"uploaded_series_uids": uploaded_series_uids,
"case": case,
"user": user,
"case_series_form": case_series_form,
},
)
@login_required
@require_POST
def uploads_import_htmx(request, case_id: int | None = None):
"""HTMX endpoint to import uploaded DICOMs with user-facing status output.
Scope: Authenticated Atlas users.
Functionality:
- Imports selected uploaded series (or all pending uploads if none selected).
- Optionally associates imported series with a target case.
- Returns an HTML fragment suitable for HTMX swap into the uploads page.
"""
selected_uids = [
uid.strip() for uid in request.POST.getlist("selection") if uid.strip()
]
dicoms = UncategorisedDicom.objects.filter(user=request.user)
if selected_uids:
dicoms = dicoms.filter(series_instance_uid__in=selected_uids)
target_case = None
if case_id is not None:
target_case = get_object_or_404(Case, pk=case_id)
if not target_case.check_user_can_edit(request.user):
return HttpResponse(
'<div class="alert alert-danger">You do not have permission to import into this case.</div>',
status=403,
)
if not dicoms.exists():
return HttpResponse(
'<div class="alert alert-warning mb-0">No pending uploads were found for the selected series.</div>'
)
try:
# Import helper lives in the API module and handles the core create/update logic.
from .api import import_dicoms_helper
imported = import_dicoms_helper(request, case_id=case_id, dicoms=dicoms)
except Exception as exc:
logger.exception("Error during uploads import")
return HttpResponse(
f'<div class="alert alert-danger mb-0">Import failed: {escape(str(exc))}</div>',
status=500,
)
imported_rows = [
{
"series": series,
"link": link,
}
for series, link in imported
]
html = render_to_string(
"atlas/partials/_upload_import_result.html",
{
"imported_rows": imported_rows,
"imported_count": len(imported_rows),
"requested_count": len(selected_uids) if selected_uids else None,
"target_case": target_case,
},
request=request,
)
response = HttpResponse(html)
response["HX-Trigger"] = json.dumps(
{
"atlas:uploadsImported": {
"imported_count": len(imported_rows),
"requested_count": len(selected_uids) if selected_uids else len(imported_rows),
}
}
)
return response
@login_required
@require_POST
def uploads_import_case_series_htmx(request):
"""Import selected uploaded studies into a linked chain of cases.
Scope: Authenticated Atlas users.
Functionality:
- Groups the selected uploaded series by study.
- Creates one case per selected study using the provided CaseForm values.
- 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()]
if not selected_uids:
return HttpResponse(
'<div class="alert alert-warning mb-0">Select at least one series before creating a case chain.</div>',
status=400,
)
form = CaseForm(request.POST, user=request.user)
if not form.is_valid():
return HttpResponse(
'<div class="alert alert-danger mb-0">Please correct the case details before importing.</div>',
status=400,
)
dicoms = (
UncategorisedDicom.objects.filter(user=request.user, series_instance_uid__in=selected_uids)
.select_related("user")
)
if not dicoms.exists():
return HttpResponse(
'<div class="alert alert-warning mb-0">No pending uploads were found for the selected series.</div>',
status=404,
)
series_to_study = {}
for dicom in dicoms.iterator():
tags = dicom.basic_dicom_tags or {}
series_uid = tags.get("SeriesInstanceUID")
if not series_uid:
continue
study_uid = tags.get("StudyInstanceUID") or tags.get("StudyID") or "unknown"
series_to_study.setdefault(series_uid, study_uid)
study_groups = defaultdict(list)
ordered_study_uids = []
seen_study_uids = set()
for series_uid in selected_uids:
study_uid = series_to_study.get(series_uid)
if not study_uid:
continue
study_groups[study_uid].append(series_uid)
if study_uid not in seen_study_uids:
seen_study_uids.add(study_uid)
ordered_study_uids.append(study_uid)
if not study_groups:
return HttpResponse(
'<div class="alert alert-warning mb-0">The selected uploads did not contain any study metadata.</div>',
status=400,
)
base_case = form.save(commit=False)
case_field_names = {
field.name
for field in Case._meta.concrete_fields
if field.name not in {"id", "previous_case"}
}
m2m_field_names = ["subspecialty", "condition", "presentation", "procedures", "pathological_process"]
created_cases = []
import_results = []
previous_case = None
try:
from .api import import_dicoms_helper
with transaction.atomic():
for study_uid in ordered_study_uids:
case = Case()
for field_name in case_field_names:
setattr(case, field_name, getattr(base_case, field_name))
case.previous_case = previous_case
study_series_uids = study_groups[study_uid]
study_dicoms = dicoms.filter(series_instance_uid__in=study_series_uids)
case.study_date = _extract_study_date_from_dicoms(study_dicoms)
case.save()
case.author.add(request.user)
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)
created_cases.append(case)
import_results.append((case, study_uid, imported_rows))
previous_case = case
except Exception as exc:
return HttpResponse(
f'<div class="alert alert-danger mb-0">Linked import failed: {escape(str(exc))}</div>',
status=500,
)
created_case_links = "".join(
f'<li><a href="{case.get_absolute_url()}">{escape(str(case))}</a> <span class="text-muted">({escape(study_uid)})</span></li>'
for case, study_uid, _ in import_results
)
imported_series_count = sum(len(rows) for _, _, rows in import_results)
html = (
'<div class="alert alert-success mb-3">'
f'Created {len(created_cases)} linked case{ "s" if len(created_cases) != 1 else "" } and imported {imported_series_count} series.'
'</div>'
'<ul class="mb-0">'
f"{created_case_links}"
'</ul>'
)
response = HttpResponse(html)
response["HX-Trigger"] = json.dumps(
{
"atlas:uploadsImported": {
"imported_count": imported_series_count,
"created_case_count": len(created_cases),
}
}
)
return response
class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Case
success_url = reverse_lazy("atlas:case_view")
template_name = "confirm_delete.html"
# TODO fix permissions
class SeriesFindingDelete(RevisionMixin, PermissionRequiredMixin, DeleteView):
permission_required = "atlas.delete_seriesfinding"
model = SeriesFinding
template_name = "confirm_delete.html"
# success_url = reverse_lazy("atlas:case_view")
def get_object(self):
series_finding = super().get_object()
series = series_finding.series
if series.check_user_can_edit(self.request.user):
return series_finding
else:
raise PermissionDenied
def get_success_url(self):
pk = self.kwargs["pk"]
series_id = get_object_or_404(SeriesFinding, pk=pk).series.id
return reverse("atlas:series_detail", kwargs={"pk": series_id})
class SeriesDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
model = Series
template_name = "confirm_delete.html"
success_url = reverse_lazy("atlas:series_view")
class CaseCollectionDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
model = CaseCollection
template_name = "confirm_delete.html"
success_url = reverse_lazy("atlas:collection_view")
class ConditionDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
model = Condition
template_name = "confirm_delete.html"
success_url = reverse_lazy("atlas:condition_view")
class FindingDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
model = Finding
template_name = "confirm_delete.html"
success_url = reverse_lazy("atlas:finding_view")
class StructureDelete(RevisionMixin, AtlasEditorRequiredMixin, DeleteView):
model = Structure
template_name = "confirm_delete.html"
success_url = reverse_lazy("atlas:structure_view")
class ResourceDelete(LoginRequiredMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Resource
template_name = "confirm_delete.html"
success_url = reverse_lazy("atlas:resource_view")
class ResourceCreate(LoginRequiredMixin, CreateView):
model = Resource
form_class = ResourceForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
return super().form_valid(form)
class ResourceView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Resource
table_class = ResourceTable
template_name = "atlas/resource_list.html"
filterset_class = ResourceFilter
def get_queryset(self):
# Base queryset
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():
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()
class CaseCollectionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = CaseCollection
form_class = CaseCollectionForm
template_name = "atlas/casecollection_form.html"
def get_initial(self):
# print(self.request)
if "pk" in self.kwargs:
initial = super(CaseCollectionCreate, self).get_initial()
# case = get_object_or_404(Case, pk=self.kwargs["pk"])
# initial["case"] = case.id
return initial
def get_form_kwargs(self):
kwargs = super(CaseCollectionCreate, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
context = super(CaseCollectionCreate, self).get_context_data(**kwargs)
# Only bind the case formset when the POST actually contains formset data.
# We don't require adding cases on create, so if the template doesn't
# include the casedetail management fields we should skip validation.
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
context["case_formset"] = CaseCollectionCaseFormSet(
self.request.POST,
self.request.FILES,
form_kwargs={"user": self.request.user},
)
context["case_formset"].full_clean()
else:
context["case_formset"] = CaseCollectionCaseFormSet(
form_kwargs={"user": self.request.user}
)
return context
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form)
case_formset = context["case_formset"]
# If the POST included case formset data, validate and save it. Otherwise
# skip formset processing so creating a collection without cases redirects
# normally to the detail view.
if self.request.POST and "casedetail_set-TOTAL_FORMS" in self.request.POST:
if case_formset.is_valid():
response = super().form_valid(form)
case_formset.instance = self.object
case_formset.save()
return response
else:
return super().form_invalid(form)
# No formset data present: proceed with normal create redirect.
return super().form_valid(form)
def get_success_url(self):
"""Redirect to the collection detail page after successful creation."""
return reverse("atlas:collection_detail", kwargs={"pk": self.object.pk})
class SeriesCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = Series
form_class = SeriesForm
def get_initial(self):
# print(self.request)
if "pk" in self.kwargs:
initial = super().get_initial()
case = get_object_or_404(Case, pk=self.kwargs["pk"])
initial["case"] = case.id
return initial
def get_form_kwargs(self):
kwargs = super(SeriesCreate, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
context = super(SeriesCreate, self).get_context_data(**kwargs)
if self.request.POST:
context["image_formset"] = SeriesImageFormSet(
self.request.POST, self.request.FILES
)
context["image_formset"].full_clean()
else:
context["image_formset"] = SeriesImageFormSet()
return context
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form)
series_formset = context["image_formset"]
if series_formset.is_valid():
response = super().form_valid(form)
series_formset.instance = self.object
series_formset.save()
return response
else:
return super().form_invalid(form)
class CaseCollectionUpdate(RevisionMixin, AuthorOrCheckerRequiredMixin, UpdateView):
model = CaseCollection
form_class = CaseCollectionForm
template_name = "atlas/casecollection_form.html"
# fields = '__all__'
# #fields = [ 'condition' ]
# #initial = {'date_of_death': '05/01/2018'}
# exclude = [ 'created_date', 'published_date' ]
def get_form_kwargs(self):
kwargs = super(CaseCollectionUpdate, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
context = super(CaseCollectionUpdate, self).get_context_data(**kwargs)
#if self.request.POST:
# context["case_formset"] = CaseCollectionCaseFormSet(
# self.request.POST,
# self.request.FILES,
# instance=self.object,
# form_kwargs={"user": self.request.user, "collection": self.object},
# )
# context["case_formset"].full_clean()
#else:
# context["case_formset"] = CaseCollectionCaseFormSet(
# instance=self.object,
# form_kwargs={"user": self.request.user, "collection": self.object},
# )
context["collection"] = context["casecollection"]
return context
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
return super().form_valid(form)
context = self.get_context_data(form=form)
case_formset = context["case_formset"]
# logger.debug(formset.is_valid())
if case_formset.is_valid():
response = super().form_valid(form)
case_formset.instance = self.object
case_formset.save()
# restore exam orders
# for exam in self.object.exams.all():
# if exam in exam_orders and self.object in exam_orders[exam]:
# print(exam_orders[exam])
# exam.exam_questions.set(exam_orders[exam])
# exam.save()
return response
else:
return super().form_invalid(form)
class SeriesUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Series
form_class = SeriesForm
# fields = '__all__'
# #fields = [ 'condition' ]
# #initial = {'date_of_death': '05/01/2018'}
# exclude = [ 'created_date', 'published_date' ]
def get_context_data(self, **kwargs):
context = super(SeriesUpdate, self).get_context_data(**kwargs)
if self.request.POST:
context["image_formset"] = SeriesImageFormSet(
self.request.POST, self.request.FILES, instance=self.object
)
context["image_formset"].full_clean()
else:
context["image_formset"] = SeriesImageFormSet(instance=self.object)
return context
def get_form_kwargs(self):
kwargs = super(SeriesUpdate, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form)
image_formset = context["image_formset"]
# logger.debug(formset.is_valid())
if image_formset.is_valid():
response = super().form_valid(form)
image_formset.instance = self.object
image_formset.save()
return response
else:
return super().form_invalid(form)
class ResourceUpdate(LoginRequiredMixin, UpdateView):
model = Resource
form_class = ResourceForm
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
return super().form_valid(form)
class ConditionUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
model = Condition
form_class = ConditionForm
class ConditionCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = Condition
form_class = ConditionForm
class QuestionSchemaCreate(LoginRequiredMixin, CreateView):
model = QuestionSchema
form_class = QuestionSchemaForm
class QuestionSchemaUpdate(AuthorOrCheckerRequiredMixin, UpdateView):
model = QuestionSchema
form_class = QuestionSchemaForm
class QuestionSchemaDelete(AuthorOrCheckerRequiredMixin, DeleteView):
model = QuestionSchema
template_name = "confirm_delete.html"
class FindingUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
model = Finding
form_class = FindingForm
class FindingCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = Finding
form_class = FindingForm
class StructureUpdate(RevisionMixin, LoginRequiredMixin, UpdateView):
model = Structure
form_class = StructureForm
class StructureCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = Structure
form_class = StructureForm
class SubspecialtyCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = Subspecialty
form_class = SubspecialtyForm
template_name = "atlas/subspecialty_form.html"
class PresentationCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = Presentation
form_class = PresentationForm
template_name = "atlas/presentation_form.html"
class PathologicalProcessCreate(RevisionMixin, LoginRequiredMixin, CreateView):
model = PathologicalProcess
form_class = PathologicalProcessForm
template_name = "atlas/pathological_process_form.html"
class AtlasCreateBase(RevisionMixin, LoginRequiredMixin):
model = Case
form_class = CaseForm
def get_form_kwargs(self):
kwargs = super(AtlasCreateBase, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
context = super(AtlasCreateBase, self).get_context_data(**kwargs)
if self.request.POST:
#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
)
context["caseresource_formset"] = CaseResourceFormSet(
self.request.POST,
self.request.FILES,
form_kwargs={"user": self.request.user},
)
#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(
# form_kwargs={"user": self.request.user}
#)
context["casedifferential_formset"] = CaseDifferentialFormSet()
context["caseresource_formset"] = CaseResourceFormSet(
form_kwargs={"user": self.request.user}
)
return context
def form_valid(self, form):
self.object = form.save(commit=False)
# Add exam objects
self.object.save()
form.instance.author.add(self.request.user.id)
# Check Cimar Case
# CimarAPI().get_study_by_uuid(self.object.cimar_uuid)
context = self.get_context_data(form=form)
#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()
):
response = super().form_valid(form)
#series_formset.instance = self.object
#series_formset.save()
casedifferential_formset.instance = self.object
casedifferential_formset.save()
caseresource_formset.instance = self.object
caseresource_formset.save()
# If the normal submit button is pressed we save as normal
if "submit" in self.request.POST:
return response
# else we redirect to the clone url
else:
return redirect("atlas:case_clone", pk=self.object.pk)
else:
return super().form_invalid(form)
# @login_required
# CaseCreate
class AtlasCreate(AtlasCreateBase, CreateView):
def get_initial(self):
if "pk" in self.kwargs:
initial = super().get_initial()
exam = get_object_or_404(CaseCollection, pk=self.kwargs["pk"])
initial["exams"] = [exam.id]
return initial
## There has to be a better way...
# try:
# s = (i.pk for i in self.request.user.atlas_default.site.all())
# self.initial.update({"site": s})
# except AttributeError:
# pass
# return self.initial
# CaseUpdate
class AtlasUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Case
form_class = CaseForm
# fields = '__all__'
# #fields = [ 'condition' ]
# #initial = {'date_of_death': '05/01/2018'}
# exclude = [ 'created_date', 'published_date' ]
#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
# ).prefetch_related(
# "series__examination",
# "series__plane",
# "series__contrast",
# "series__images",
# "subspecialty",
# "condition",
# "presentation",
# "pathological_process",
# "differentialcase",
# "caseresource_set",
# )
# return qs.get(pk=self.kwargs["pk"])
def get_form_kwargs(self):
kwargs = super(AtlasUpdate, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def get_context_data(self, **kwargs):
context = super(AtlasUpdate, self).get_context_data(**kwargs)
if self.request.POST:
#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
)
context["caseresource_formset"] = CaseResourceFormSet(
self.request.POST,
self.request.FILES,
instance=self.object,
form_kwargs={"user": self.request.user},
)
#context["series_formset"].full_clean()
context["casedifferential_formset"].full_clean()
context["caseresource_formset"].full_clean()
else:
#context["series_formset"] = SeriesFormSet(
# instance=self.object, form_kwargs={"user": self.request.user}
#)
context["casedifferential_formset"] = CaseDifferentialFormSet(
instance=self.object
)
context["caseresource_formset"] = CaseResourceFormSet(
instance=self.object,
form_kwargs={"user": self.request.user},
)
return context
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
form.instance.author.add(self.request.user.id)
context = self.get_context_data(form=form)
#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()
):
response = super().form_valid(form)
#series_formset.instance = self.object
#series_formset.save()
casedifferential_formset.instance = self.object
casedifferential_formset.save()
caseresource_formset.instance = self.object
caseresource_formset.save()
return response
else:
return super().form_invalid(form)
class CollectionCaseUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView):
model = CaseCollection
form_class = CaseCollectionUpdateCaseForm
template_name = "atlas/casecollection_update_case.html"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
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:
context["case_formset"] = CaseCollectionCaseFormSet(
self.request.POST,
self.request.FILES,
instance=self.object,
form_kwargs={"user": self.request.user, "collection": self.object},
)
context["case_formset"].full_clean()
else:
context["case_formset"] = CaseCollectionCaseFormSet(
instance=self.object,
form_kwargs={"user": self.request.user, "collection": self.object},
)
context["collection"] = context["casecollection"]
return context
def form_valid(self, form):
context = self.get_context_data(form=form)
case_formset = context["case_formset"]
if case_formset.is_valid():
case_formset.instance = self.object
case_formset.save()
return super().form_valid(form)
else:
return self.form_invalid(form)
class AtlasSeriesUpdate(
RevisionMixin, LoginRequiredMixin, AuthorOrCheckerRequiredMixin, UpdateView
):
model = Case
form_class = CaseUpdateSeriesForm
template_name = "atlas/case_update_series.html"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
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
form_kwargs = {"user": self.request.user, "is_atlas_editor": is_atlas_editor}
if not is_atlas_editor:
queryset = Series.objects.filter(author__id=self.request.user.id)
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"))
)
if self.request.POST:
context["series_formset"] = SeriesFormSet(
self.request.POST,
self.request.FILES,
queryset=queryset,
instance=self.object,
form_kwargs=form_kwargs,
)
context["series_formset"].full_clean()
else:
context["series_formset"] = SeriesFormSet(
queryset=queryset,
instance=self.object,
form_kwargs=form_kwargs,
)
return context
def form_valid(self, form):
context = self.get_context_data(form=form)
series_formset = context["series_formset"]
if series_formset.is_valid():
series_formset.instance = self.object
series_formset.save()
return super().form_valid(form)
else:
return self.form_invalid(form)
def get_object(self, queryset=None):
qs = Case.objects.select_related().prefetch_related(
"seriesdetail_set__series__modality",
"seriesdetail_set__series__examination",
"seriesdetail_set__series__plane",
"seriesdetail_set__series__contrast",
"seriesdetail_set__series__images",
)
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)
if form.is_valid():
case = get_object_or_404(Case, pk=case_id)
case.casecollection_set.add(*form.cleaned_data["casecollection"])
# do_something_with_form_data(form.cleaned_data)
return HttpResponse("Done")
return render(request, "web/htmx_contact_form_confirm.html")
else:
form = AddCollectionToCaseForm(user=request.user)
# Resolve the Case early so templates always receive it when available
try:
case = Case.objects.get(pk=case_id)
except Exception:
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"
if is_htmx:
# Provide the 10 most recently updated collections for this user by default
user = request.user
recent_base = (
CaseCollection.objects.filter(Q(author=user) | Q(markers=user))
.distinct()
.order_by("-updated")
)
# 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_qs = recent_base[:10]
html = render_to_string(
"atlas/partials/add_collection_inline.html",
{"recent_collections": recent_qs, "case": case},
request=request,
)
return HttpResponse(html)
return render(
request,
"generic_form.html",
{
"form": form,
"target_url": reverse("atlas:case_collection_form", args=(case_id,)),
},
)
@login_required
def collection_inline_search(request, case_id):
# HTMX endpoint: return recent collections or search results for the user
if not request.htmx:
return Http404
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()
)
try:
case = Case.objects.get(pk=case_id)
except Exception:
case = None
# 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))
if q:
results = base_qs.filter(name__icontains=q).order_by("-updated")[:30]
else:
results = base_qs.order_by("-updated")[:10]
html = render_to_string(
"atlas/partials/collection_inline_list.html",
{"collections": results, "case": case},
request=request,
)
return HttpResponse(html)
@login_required
@require_http_methods(["POST"])
def add_collection_to_case_inline(request, case_id, collection_id):
# HTMX endpoint to add a single collection to a case and return the new list item
if not request.htmx:
return Http404
case = get_object_or_404(Case, pk=case_id)
collection = get_object_or_404(CaseCollection, pk=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)
# Add the association
if collection in case.casecollection_set.all():
return HttpResponse("Already in collection")
case.casecollection_set.add(collection)
# 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)},
request=request,
)
# Also instruct the client to remove the collection entry from the inline search list
remove_script = (
'<script>'
'(function(){'
'try {'
"var pk = '%s';" % collection.pk +
"document.querySelectorAll('#collection-inline-list [data-collection-pk=\"' + pk + '\"]').forEach(function(el){ el.remove(); });" +
'} catch(e) { console.error("remove added collection from inline list error", e); }'
'})();'
'</script>'
)
return HttpResponse(rendered + remove_script)
# class AddCollectionToCaseView(FormView):
# form_class = AddCollectionToCaseForm
# template_name = "generic_form.html"
#
# def form_valid(self, form):
# form.save()
# return super(AddCollectionToCaseView, self).form_valid(form)
#
# def get_form_kwargs(self):
# kwargs = super(AddCollectionToCaseView, self).get_form_kwargs()
# kwargs.update({"user": self.request.user})
# return kwargs
#
# def get_success_url(self) -> str:
# pk = self.kwargs["pk"]
# return reverse("atlas:case_detail", kwargs={"pk": pk})
class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
"""Clones a existing atlas case"""
# fields = '__all__'
# #fields = [ 'condition' ]
# #initial = {'date_of_death': '05/01/2018'}
# exclude = [ 'created_date', 'published_date' ]
def get_initial(self):
# print(self.request)
old_object = get_object_or_404(Case, pk=self.kwargs["pk"])
if (
self.request.user.groups.filter(name="atlas_editor").exists()
or self.request.user.is_superuser
):
pass
else:
if self.request.user not in old_object.get_author_objects():
raise PermissionDenied() # or Http404
else:
pass
initial_data = model_to_dict(old_object, exclude=["id", "previous_case", "history", "discussion", "report"])
return initial_data
class AtlasCloneNext(AtlasClone):
"""Extends AtlasClone to set the previous_case field to the case being cloned"""
def get_initial(self):
initial_data = super().get_initial()
initial_data["previous_case"] = self.kwargs["pk"]
return initial_data
@login_required
@user_is_author_or_atlas_editor
def atlas_scrap(request, pk):
try:
atlas = Case.objects.get(pk=pk)
except Case.DoesNotExist:
raise Http404("Case does not exist")
atlas.scrapped = not atlas.scrapped
atlas.save()
return HttpResponseRedirect(reverse("atlas:case_detail", args=(pk,)))
# @login_required
# def edit_abnormality_popup(request):
# instance = get_object_or_404(Abnormality, pk = pk)
# form = AbnormalityForm(request.POST or None)
# if form.is_valid():
# instance = form.save()
# return HttpResponse('<script>opener.closePopup(window, "%s", "%s", "#id_abnormality");</script>' % (instance.pk, instance))
# return render(request, "atlas/create_simple.html", {'form': form})
@login_required
def get_examination_id(request):
"""Return the primary key for an Examination looked up by name (admin popup helper).
Scope: authenticated users only.
Functionality: looks up an Examination by exact name and returns its ID as JSON;
used by inline admin form popups.
"""
if request.accepts("application/json"):
examination_name = request.GET.get("examination_name", "")
examination_id = Examination.objects.get(name=examination_name).id
data = {
"examination_id": examination_id,
}
return HttpResponse(json.dumps(data), content_type="application/json")
return HttpResponse("/")
class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Case
table_class = CaseTable
template_name = "atlas/case_view.html"
filterset_class = CaseFilter
@staff_member_required
def case_admin_overview(request):
"""Admin overview for case ownership and storage-heavy case monitoring.
Scope: staff users.
Functionality: shows per-author case counts and summary metrics, with recent
and largest case lists loaded on demand via HTMX partials.
"""
author_rows = (
User.objects.filter(atlas_authored_cases__isnull=False)
.annotate(case_count=Count("atlas_authored_cases", distinct=True))
.order_by("-case_count", "username")
)
totals = {
"total_cases": Case.objects.count(),
"total_authors": author_rows.count(),
"open_access_cases": Case.objects.filter(open_access=True).count(),
"archived_cases": Case.objects.filter(archive=True).count(),
}
return render(
request,
"atlas/case_admin_overview.html",
{
"author_rows": author_rows,
"totals": totals,
},
)
@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]
return render(
request,
"atlas/partials/_case_admin_recent_cases.html",
{"recent_cases": recent_cases},
)
@staff_member_required
def case_admin_largest_cases_partial(request):
"""HTMX partial for largest cases by total series image size."""
def human_size(num_bytes):
n = float(num_bytes or 0)
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024.0:
return f"{n:.1f}{unit}"
n /= 1024.0
return f"{n:.1f}PB"
rows = []
for case in Case.objects.prefetch_related("series__images").all():
try:
total = case.get_total_series_images_size()
except Exception:
total = 0
rows.append(
{
"case": case,
"total": total,
"human_total": human_size(total),
"series_count": case.series.count(),
}
)
rows.sort(key=lambda row: row["total"], reverse=True)
return render(
request,
"atlas/partials/_case_admin_largest_cases.html",
{"largest_rows": rows[:30]},
)
@staff_member_required
def cases_by_size(request):
"""Admin view: list cases ordered by total images size to find large cases.
Shows case id, title, number of series, number of images and total size (bytes).
Optional GET parameter `min_size` (bytes) to filter cases larger than that.
"""
min_size = request.GET.get("min_size")
try:
min_size = int(min_size) if min_size else 0
except Exception:
min_size = 0
cases_qs = Case.objects.prefetch_related("series__images")
def human(n):
# simple human readable bytes
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024.0:
return f"{n:.1f}{unit}"
n /= 1024.0
return f"{n:.1f}PB"
rows = []
for c in cases_qs:
try:
total = c.get_total_series_images_size()
except Exception:
total = 0
series_count = c.series.count()
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.sort(key=lambda r: r["total"], reverse=True)
rows.sort(key=lambda r: r["total"], reverse=True)
paginator = Paginator(rows, 50)
page_num = request.GET.get("page")
page = paginator.get_page(page_num)
def human(n):
# simple human readable bytes
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024.0:
return f"{n:.1f}{unit}"
n /= 1024.0
return f"{n:.1f}PB"
context = {"page": page, "human": human, "min_size": min_size}
return render(request, "atlas/cases_by_size.html", context)
class SeriesView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Series
table_class = SeriesTable
template_name = "atlas/series_view.html"
filterset_class = SeriesFilter
class ConditionView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Condition
table_class = ConditionTable
template_name = "atlas/view.html"
filterset_class = ConditionFilter
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
try:
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
except NoReverseMatch:
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)
return ctx
class QuestionSchemaView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = QuestionSchema
table_class = QuestionSchemaTable
template_name = "atlas/schema_table_view.html"
filterset_class = QuestionSchemaFilter
class SubspecialtyView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Subspecialty
table_class = SubspecialtyTable
template_name = "atlas/view.html"
filterset_class = SubspecialtyFilter
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
try:
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
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)
return ctx
class PresentationView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Presentation
table_class = PresentationTable
template_name = "atlas/view.html"
filterset_class = PresentationFilter
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
try:
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
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)
return ctx
class ProcedureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, ListView):
model = Procedure
table_class = __import__('atlas.tables', fromlist=['ProcedureTable']).ProcedureTable
template_name = "atlas/view.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
try:
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
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)
return ctx
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 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)
def form_invalid(self, form):
logger.warning("ProcedureCreate form invalid: %s", form.errors)
return super().form_invalid(form)
def get_success_url(self):
try:
return reverse("atlas:procedure_detail", kwargs={"pk": self.object.pk})
except Exception:
return reverse("atlas:procedure_view")
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)
def form_invalid(self, form):
logger.warning("ProcedureUpdate form invalid: %s", form.errors)
return super().form_invalid(form)
def get_success_url(self):
try:
return reverse("atlas:procedure_detail", kwargs={"pk": self.object.pk})
except Exception:
return reverse("atlas:procedure_view")
class ProcedureDelete(RevisionMixin, LoginRequiredMixin, DeleteView):
model = Procedure
template_name = "confirm_delete.html"
class PathologicalProcessView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = PathologicalProcess
table_class = PathologicalProcessTable
template_name = "atlas/view.html"
filterset_class = PathologicalProcessFilter
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
try:
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
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)
return ctx
class StructureView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Structure
table_class = StructureTable
template_name = "atlas/view.html"
filterset_class = StructureFilter
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
try:
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
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)
return ctx
class FindingView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableMixin, FilterView):
model = Finding
table_class = FindingTable
template_name = "atlas/view.html"
filterset_class = FindingFilter
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
try:
ctx["create_url"] = reverse(f"atlas:{self.model._meta.model_name}_create")
except NoReverseMatch:
ctx["create_url"] = None
return ctx
@login_required
def case_order_dicom(request, pk):
if request.htmx:
case: Case = get_object_or_404(Case, pk=pk)
fail = []
# TODO: don't try to order non stack series
for series in case.series.all():
try:
if series.check_user_can_edit(request.user):
series.order_by_dicom()
else:
fail.append(f"{series.pk}: invalid permission")
except:
fail.append(f"{series.pk}: error")
if fail:
return HttpResponse(
f"Unable to order series {','.join([str(i) for i in fail])}"
)
else:
return HttpResponse("Series reordered")
raise Http404
@login_required
@user_is_author_or_atlas_series_checker
def series_order_dicom(request, pk):
series = get_object_or_404(Series, pk=pk)
try:
series.order_by_dicom()
except:
return HttpResponse("<h1>Series does not appear to contain dicoms</h1>")
return redirect("atlas:series_detail", pk=pk)
@login_required
def image_diff(request):
if request.htmx:
image_ids = request.POST.getlist("image-id")
if len(image_ids) == 2:
first: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[0])
second: SeriesImage = get_object_or_404(SeriesImage, pk=image_ids[1])
first_ds = pydicom.dcmread(first.image.file)
# del first_ds.PixelData
# j1 = first_ds.to_json_dict()
f = print_dicom(first_ds, join=False)
second_ds = pydicom.dcmread(second.image.file)
# del second_ds.PixelData
# j2 = second_ds.to_json_dict()
s = print_dicom(second_ds, join=False)
diff = difflib.HtmlDiff().make_table(f, s)
dict_diff = pprint.pformat(compare_dicom_datasets(first_ds, second_ds))
# return HttpResponse(diff)
return HttpResponse(f"<pre>{dict_diff}</pre><br/>{diff}")
else:
ds_set = []
for pk in image_ids:
series_image = get_object_or_404(SeriesImage, pk=pk)
ds_set.append(pydicom.dcmread(series_image.image.file))
dict_diff = pprint.pformat(compare_dicom_datasets(*ds_set))
return HttpResponse(f"<pre>{dict_diff}</pre>")
return HttpResponse("Fail")
@login_required
def series_image_dicom(request, pk):
image = get_object_or_404(SeriesImage, pk=pk)
return render(request, "atlas/series_image_dicom.html", {"image": image})
@login_required
@user_is_author_or_atlas_series_checker
def series_anonymise_dicom(request, pk):
series = get_object_or_404(Series, pk=pk)
series.anonymise_images()
return redirect("atlas:series_detail", pk=pk)
@login_required
@user_is_author_or_atlas_series_checker
def series_order_dicom_instance(request, pk):
series = get_object_or_404(Series, pk=pk)
try:
series.order_by_dicom("InstanceNumber")
except:
return HttpResponse(
"<h1>Series does not appear to contain dicoms (or field InstanceNumber)</h1>"
)
return redirect("atlas:series_detail", pk=pk)
@login_required
@user_is_author_or_atlas_series_checker
def series_order_dicom_SeriesInstanceUID(request, pk):
series = get_object_or_404(Series, pk=pk)
try:
series.order_by_dicom("SeriesInstanceUID")
except:
return HttpResponse(
"<h1>Series does not appear to contain dicoms (or field SeriesInstanceUID)</h1>"
)
return redirect("atlas:series_detail", pk=pk)
@login_required
@user_is_author_or_atlas_series_checker
def series_order_upload_filename(request, pk):
series = get_object_or_404(Series, pk=pk)
series.order_by_upload_filename()
return redirect("atlas:series_detail", pk=pk)
# Common DICOM tags available for series splitting
SERIES_SPLIT_TAGS = [
("ImageType", "Image Type"),
("EchoNumbers", "Echo Numbers (multi-echo MRI)"),
("EchoTime", "Echo Time"),
("DiffusionBValue", "Diffusion B-Value (DWI)"),
("TemporalPositionIdentifier", "Temporal Position (dynamic series)"),
("AcquisitionNumber", "Acquisition Number"),
("FlipAngle", "Flip Angle"),
("ContrastBolusAgent", "Contrast/Bolus Agent"),
("CardiacNumberOfImages", "Cardiac Number of Images"),
]
def _normalise_dicom_split_value(value):
if isinstance(value, pydicom.multival.MultiValue):
return ",".join(map(str, value))
return str(value)
def _summarise_dicom_value(value):
if isinstance(value, pydicom.multival.MultiValue):
value_text = ",".join(map(str, value))
elif isinstance(value, (bytes, bytearray)):
value_text = f"<binary:{len(value)} bytes>"
else:
value_text = str(value)
if len(value_text) > 120:
return f"{value_text[:117]}..."
return value_text
def _analyse_series_split_options(series):
tags = [tag_value for tag_value, _ in SERIES_SPLIT_TAGS]
tag_value_sets = {tag: set() for tag in tags}
tag_missing = {tag: False for tag in tags}
for image in series.images.iterator():
ds = image.get_dicom_data()
for tag in tags:
if tag in ds:
tag_value_sets[tag].add(_normalise_dicom_split_value(ds[tag].value))
else:
tag_missing[tag] = True
split_options = []
for tag_value, tag_label in SERIES_SPLIT_TAGS:
if tag_missing[tag_value]:
continue
split_count = len(tag_value_sets[tag_value])
if split_count > 1:
split_options.append(
{
"tag_value": tag_value,
"tag_label": tag_label,
"split_count": split_count,
}
)
split_options.sort(key=lambda item: item["split_count"], reverse=True)
return split_options
@login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_tag_consistency(request, pk):
"""Analyze DICOM tags across all images in a series.
Scope: Any user with view access to the series.
Functionality:
- GET: returns a panel with an optional on-demand analysis.
- GET ?analyze=1: scans all tags across images and groups them by:
1) same in all images, 2) different across images, 3) missing in some images.
"""
series = get_object_or_404(Series, pk=pk)
analyze_requested = request.GET.get("analyze") == "1"
can_split = series.check_user_can_edit(request.user)
same_tags = []
different_tags = []
partial_tags = []
image_count = 0
if analyze_requested:
tag_values = defaultdict(set)
tag_presence = defaultdict(int)
for image in series.images.iterator():
image_count += 1
ds = image.get_dicom_data()
seen_in_image = set()
for element in ds.iterall():
# PixelData is large and not useful for consistency checks.
if element.tag == (0x7FE0, 0x0010):
continue
tag_name = element.keyword or str(element.tag)
tag_values[tag_name].add(_summarise_dicom_value(element.value))
if tag_name not in seen_in_image:
tag_presence[tag_name] += 1
seen_in_image.add(tag_name)
for tag_name in sorted(tag_values.keys()):
present_count = tag_presence[tag_name]
unique_values = tag_values[tag_name]
unique_count = len(unique_values)
sample_values = sorted(unique_values)[:3]
row = {
"tag_name": tag_name,
"present_count": present_count,
"unique_count": unique_count,
"sample_values": sample_values,
}
if present_count == image_count and unique_count == 1:
same_tags.append(row)
elif present_count == image_count:
different_tags.append(row)
else:
partial_tags.append(row)
return render(
request,
"atlas/partials/_series_tag_consistency.html",
{
"series": series,
"analyze_requested": analyze_requested,
"can_split": can_split,
"image_count": image_count,
"same_tags": same_tags,
"different_tags": different_tags,
"partial_tags": partial_tags,
},
)
@login_required
@user_is_author_or_atlas_series_checker
def series_split_by_tag(request, pk):
"""Split a series into multiple series based on unique values of a chosen DICOM tag.
Scope: Atlas series authors and series checkers only (via decorator).
Functionality:
- GET: renders a partial form and, on demand, analyzes available DICOM tags
to list only tags that can actually split the series.
- POST: performs the split. The original series retains the images matching
the first unique tag value; new series are cloned for each subsequent value.
On success, returns an HTMX success fragment with number of series created.
On error (tag not found or non-DICOM images), returns an HTMX-swappable
error message fragment.
"""
series = get_object_or_404(Series, pk=pk)
if request.method == "GET":
analyze_requested = request.GET.get("analyze") == "1"
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,
},
)
dicom_tag = request.POST.get("dicom_tag", "").strip()
if not dicom_tag:
return HttpResponse(
'<div class="alert alert-danger">No DICOM tag specified.</div>'
)
# 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(",")]
)
image_map = defaultdict(list)
for image in series.images.iterator():
ds = image.get_dicom_data()
if dicom_tag not in ds:
return HttpResponse(
f'<div class="alert alert-danger">Tag <code>{escape(str(dicom_tag))}</code> not found in image {escape(str(image))}.</div>'
)
key = _normalise_dicom_split_value(ds[dicom_tag].value)
image_map[key].append(image)
if len(image_map) < 2:
return HttpResponse(
f'<div class="alert alert-warning">Tag <code>{escape(str(dicom_tag))}</code> has only one unique value, so no split was performed.</div>'
)
case = list(series.case.all())
authors = list(series.author.all())
new_series_pks = []
with transaction.atomic():
for n, (option, images) in enumerate(image_map.items()):
if n == 0:
series.images.set(images)
new_series_pks.append(series.pk)
else:
series.pk = None
series.save()
series.case.set(case)
series.author.set(authors)
series.images.set(images)
new_series_pks.append(series.pk)
logger.info(
"Series split by tag '{}': produced series PKs {}",
dicom_tag,
new_series_pks,
)
return HttpResponse(
f'<div class="alert alert-success">Split complete: <strong>{len(new_series_pks)}</strong> series created from tag <code>{escape(str(dicom_tag))}</code>.</div>'
)
# Vendored in from BaseZipView
class SeriesImagesZipView(SeriesImagesZipViewBase, SuperuserRequiredMixin):
series_object = Series
@login_required
def case_download(request, pk):
"""Allow a case author (or superuser) to download all linked series DICOMs
and a metadata export as a zip file.
"""
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()):
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:
metadata = {
"case": {
"id": case.pk,
"title": case.title,
"authors": [str(a) for a in case.author.all()],
# Non-DICOM metadata
"description": case.description,
"history": case.history,
"discussion": case.discussion,
"report": case.report,
"created_date": getattr(case, "created_date", None),
"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()],
"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,
},
"series": [],
}
# Iterate ordered series if available, otherwise fall back to related series
try:
ordered_series = case.get_ordered_series()
except Exception:
ordered_series = list(case.series.all())
for series in ordered_series:
series_entry = {"pk": series.pk, "description": str(series)}
# Prefer series-provided dicom JSON if available
try:
series_entry["dicom_json"] = series.get_series_dicom_json()
except Exception:
series_entry["dicom_json"] = None
images = series.images.filter(removed=False).order_by("position")
series_entry["images"] = []
for img in images:
try:
file_obj = img.image.file
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})
except Exception:
# skip missing files
continue
metadata["series"].append(series_entry)
# Write metadata.json
zip_file.writestr("metadata.json", json.dumps(metadata, default=str, indent=2))
file_size = temp_file.tell()
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-Length"] = file_size
return response
# At the moment only the series owner / author can delete findings but anyone can create
def create_series_findings(request):
# posts = Post.objects.all()
response_data = {}
if request.POST.get("action") == "post":
series_finding_id = int(request.POST.get("series_finding_id"))
series_id = request.POST.get("series")
findings_ids = json.loads(request.POST.get("findings"))
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_3d = request.POST.get("annotation_json_3d")
viewer_state_3d = request.POST.get("viewer_state_3d")
series = Series.objects.get(pk=series_id)
findings = Finding.objects.filter(pk__in=findings_ids)
structures = Structure.objects.filter(pk__in=structure_ids)
conditions = Condition.objects.filter(pk__in=condition_ids)
if series_finding_id > 0:
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
else:
sf = SeriesFinding.objects.create(
series=series,
description=description,
#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.annotation_json_3d = annotation_json_3d
sf.viewer_state_3d = viewer_state_3d
sf.save()
return JsonResponse({"success": True})
return JsonResponse({"success": False})
return render(request, "create_post.html", {"posts": posts})
class ConditionAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
# Don't forget to filter out results depending on the visitor !
if not self.request.user.is_authenticated:
return Condition.objects.none()
qs = Condition.objects.all()
if self.q:
# This raises a fielderror which breaks creating a new item if not caught
try:
qs = qs.filter(name__icontains=self.q)
except FieldError:
return Condition.objects.none()
return qs
class ProcedureAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
if not self.request.user.is_authenticated:
return Procedure.objects.none()
qs = Procedure.objects.all()
if self.q:
try:
qs = qs.filter(name__icontains=self.q)
except FieldError:
return Procedure.objects.none()
return qs
class CaseCollectionPrerequisiteAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
if not self.request.user.is_authenticated:
return CaseCollection.objects.none()
qs = CaseCollection.objects.filter(author=self.request.user).distinct().order_by("name")
if self.q:
try:
qs = qs.filter(name__icontains=self.q)
except FieldError:
return CaseCollection.objects.none()
return qs
class ConditionAutocompleteRCRCurriculum(autocomplete.Select2QuerySetView):
def get_queryset(self):
# Don't forget to filter out results depending on the visitor !
if not self.request.user.is_authenticated:
return Condition.objects.none()
qs = Condition.objects.filter(rcr_curriculum=True)
if self.q:
# This raises a fielderror which breaks creating a new item if not caught
try:
qs = qs.filter(name__icontains=self.q, rcr_curriculum=True)
except FieldError:
return Condition.objects.none()
return qs
class PresentationAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
# Don't forget to filter out results depending on the visitor !
if not self.request.user.is_authenticated:
return Presentation.objects.none()
qs = Presentation.objects.all()
if self.q:
# This raises a fielderror which breaks creating a new item if not caught
try:
qs = qs.filter(name__icontains=self.q)
except FieldError:
return Presentation.objects.none()
return qs
class FindingAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
# Don't forget to filter out results depending on the visitor !
if not self.request.user.is_authenticated:
return Finding.objects.none()
qs = Finding.objects.all()
if self.q:
# This raises a fielderror which breaks creating a new item if not caught
try:
qs = qs.filter(name__icontains=self.q)
except FieldError:
return Finding.objects.none()
return qs
class StructureAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
# Don't forget to filter out results depending on the visitor !
if not self.request.user.is_authenticated:
return Structure.objects.none()
qs = Structure.objects.all()
if self.q:
# This raises a fielderror which breaks creating a new item if not caught
try:
qs = qs.filter(name__icontains=self.q)
except FieldError:
return Structure.objects.none()
return qs
def has_add_permission(self, request):
"""Allow any authenticated user to create new Structure entries.
By default DAL requires the user to have the model add permission.
For Anatomy question creation we want any registered user to be
able to create structures, so relax that check to only require
authentication.
"""
try:
auth = request.user.is_authenticated
except Exception:
auth = False
return bool(auth)
class SubspecialtyAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
# Don't forget to filter out results depending on the visitor !
if not self.request.user.is_authenticated:
return Subspecialty.objects.none()
qs = Subspecialty.objects.all()
if self.q:
# This raises a fielderror which breaks creating a new item if not caught
try:
qs = qs.filter(name__icontains=self.q)
except FieldError:
return Subspecialty.objects.none()
return qs
@login_required
# @user_is_atlas_editor
def categories_list(request):
# Allow an optional global search across category models via GET `q`.
query = request.GET.get("q", "").strip()
# Optional `types` GET parameter (can be repeated) to restrict which category
# models to search. Expected values: 'conditions','findings','structures',
# 'subspecialties','presentations'. If omitted, search all.
types = request.GET.getlist("types")
results = None
if query:
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).
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 (),
}
return render(request, "atlas/categories_list.html", {"query": query, "results": results})
@login_required
def categories_search_partial(request):
"""Return only the search results partial for HTMX requests.
Expects GET param `q`.
"""
query = request.GET.get("q", "").strip()
types = request.GET.getlist("types")
results = {"conditions": (), "findings": (), "structures": (), "subspecialties": (), "presentations": (), "procedures": ()}
if query:
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 (),
}
return render(request, "atlas/_categories_search_results.html", {"query": query, "results": results})
# def collection_view(request):
# collections = CaseCollection.objects.all()
#
# return render(
# request, "atlas/collection_view.html", {"collections": collections}
# )
class CollectionView(LoginRequiredMixin, SingleTableMixin, FilterView):
model = CaseCollection
table_class = CaseCollectionTable
template_name = "atlas/collection_view.html"
filterset_class = CaseCollectionFilter
@user_is_collection_author_or_atlas_editor
def collection_viva(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk)
#cases = (
# collection.cases.all()
# .order_by("casedetail__sort_order")
# .prefetch_related(
# "series",
# "series__images",
# "series__contrast",
# "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
# - Prefetch only non-removed images for those series (ordered by position)
# - Prefetch case resources and display sets
from django.db.models import Prefetch as _Prefetch
series_images_qs = SeriesImage.objects.filter(removed=False).order_by("position")
series_qs = Series.objects.order_by("seriesdetail__sort_order").prefetch_related(
_Prefetch("images", queryset=series_images_qs)
)
casedetails = (
CaseDetail.objects
.filter(collection=collection)
.select_related("case")
.order_by("sort_order")
.prefetch_related(
_Prefetch("case__series", queryset=series_qs),
_Prefetch("case__series__modality"),
_Prefetch("case__series__examination"),
_Prefetch("case__series__plane"),
_Prefetch("case__series__contrast"),
_Prefetch("case__caseresource_set"),
_Prefetch("case__display_sets"),
)
.annotate(series_count=Count("case__seriesdetail"))
)
return render(
request,
"atlas/collection_viva.html",
{"collection": collection, "casedetails": casedetails, "can_edit": True},
)
@user_is_collection_author_or_atlas_editor
def collection_viva_local(request, pk):
"""
Local-only viva controller view.
Scope:
- Mirrors `collection_viva` functionality for case/series/displayset loading.
- Uses only the embedded local DV3D viewer workflow.
- Keeps the original viva route intact for side-by-side rollout.
"""
collection = get_object_or_404(CaseCollection, pk=pk)
from django.db.models import Prefetch as _Prefetch
series_images_qs = (
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")
.order_by("seriesdetail__sort_order")
.prefetch_related(_Prefetch("images", queryset=series_images_qs, to_attr="prefetched_images"))
)
casedetails = list(
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"),
)
.annotate(series_count=Count("case__seriesdetail"))
)
for casedetail in casedetails:
case = casedetail.case
prefetched_series = getattr(case, "prefetched_series", [])
visible_prefetched_series = []
images_nested = []
for series in prefetched_series:
if (
casedetail.get_series_visibility_for(series, review_mode=False)
== CaseDetail.SeriesVisibility.HIDDEN
):
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)
])
case.viva_details_json = json.dumps(
{
"title": case.title,
"description": case.description,
"history": case.history,
"discussion": case.discussion,
"report": case.report,
}
)
case.visible_prefetched_series = visible_prefetched_series
case.series_images_nested_json = json.dumps(images_nested)
return render(
request,
"atlas/collection_viva_local.html",
{"collection": collection, "casedetails": casedetails, "can_edit": True},
)
@user_is_collection_author_or_atlas_editor
def collection_viva_case(request, pk, case_number):
collection = get_object_or_404(CaseCollection, pk=pk)
# cases = collection.cases.all().order_by("casedetail__sort_order")
current_case = collection.get_case_by_index(case_number)
return render(
request,
"atlas/collection_viva_case.html",
{"collection": collection, "current_case": current_case},
)
@user_is_collection_author_or_atlas_editor
def collection_viva_series(request, pk, series_id):
collection = get_object_or_404(CaseCollection, pk=pk)
series = get_object_or_404(Series, pk=series_id)
return render(
request,
"atlas/collection_viva_series.html",
{"collection": collection, "current_series": series},
)
@user_is_collection_author_or_atlas_editor
def collection_detail(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk)
collection_content_type = ContentType.objects.get_for_model(collection)
messaging_enabled = _collection_case_query_messaging_enabled(collection)
# cases = collection.cases.all().order_by("casedetail__sort_order")
casedetails = (
CaseDetail.objects
.filter(collection=collection)
.select_related("case")
.annotate(series_count=Count("case__seriesdetail"))
.order_by("sort_order")
)
if messaging_enabled:
outstanding_messages = CaseReviewMessage.objects.filter(
cid_user_exam__content_type=collection_content_type,
cid_user_exam__object_id=collection.pk,
requires_acknowledgement=True,
acknowledged_at__isnull=True,
)
total_outstanding_messages = outstanding_messages.count()
user_message_rows = list(
collection.get_user_exams()
.select_related("user_user")
.annotate(
outstanding_message_count=Count(
"case_review_messages",
filter=Q(
case_review_messages__requires_acknowledgement=True,
case_review_messages__acknowledged_at__isnull=True,
),
distinct=True,
),
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")
)
cid_message_rows = list(
collection.get_cid_exams()
.select_related("cid_user")
.annotate(
outstanding_message_count=Count(
"case_review_messages",
filter=Q(
case_review_messages__requires_acknowledgement=True,
case_review_messages__acknowledged_at__isnull=True,
),
distinct=True,
),
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")
)
else:
total_outstanding_messages = 0
user_message_rows = []
cid_message_rows = []
return render(
request,
"atlas/collection_detail.html",
{
"collection": collection,
"casesdetails": casedetails,
"can_edit": True,
"exam": collection,
"total_outstanding_messages": total_outstanding_messages,
"user_message_rows": user_message_rows,
"cid_message_rows": cid_message_rows,
},
)
@user_is_collection_author_or_atlas_editor
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"))
if _collection_case_query_messaging_enabled(collection):
userexams = list(
collection.get_user_exams()
.select_related("user_user")
.annotate(
outstanding_feedback_count=Count(
"case_review_messages",
filter=Q(
case_review_messages__requires_acknowledgement=True,
case_review_messages__acknowledged_at__isnull=True,
),
distinct=True,
),
total_review_message_count=Count("case_review_messages", distinct=True),
)
.order_by("-outstanding_feedback_count", "completed", "-start_time")
)
cidexams = list(
collection.get_cid_exams()
.select_related("cid_user")
.annotate(
outstanding_feedback_count=Count(
"case_review_messages",
filter=Q(
case_review_messages__requires_acknowledgement=True,
case_review_messages__acknowledged_at__isnull=True,
),
distinct=True,
),
total_review_message_count=Count("case_review_messages", distinct=True),
)
.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"))
for userexam in userexams:
userexam.outstanding_feedback_count = 0
userexam.total_review_message_count = 0
for cidexam in cidexams:
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
)
completed_attempt_count = sum(1 for item in userexams if item.completed) + sum(
1 for item in cidexams if item.completed
)
total_attempt_count = len(userexams) + len(cidexams)
return render(
request,
"atlas/collection_history.html",
{
"collection": collection,
"casesdetails": casedetails,
"can_edit": True,
"userexams": userexams,
"cidexams": cidexams,
"total_cases": len(casedetails),
"total_attempt_count": total_attempt_count,
"completed_attempt_count": completed_attempt_count,
"total_outstanding_messages": total_outstanding_messages,
},
)
def _get_case_review_stats_for_exam(
*,
cid_user_exam: CidUserExam,
casedetails: list[CaseDetail] | QuerySet[CaseDetail],
messaging_enabled: bool = True,
) -> tuple[dict[int, dict[str, int | bool]], int, int]:
case_ids = [casedetail.case_id for casedetail in casedetails]
stats_by_case = {
case_id: {
"message_count": 0,
"outstanding_feedback_count": 0,
"outstanding_query_count": 0,
"outstanding_total_count": 0,
"has_messages": False,
"has_outstanding_feedback": False,
"has_outstanding_query": False,
"has_outstanding_any": False,
}
for case_id in case_ids
}
if not messaging_enabled:
return stats_by_case, 0, 0
message_rows = (
CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case_id__in=case_ids)
.values("case_id")
.annotate(
message_count=Count("id"),
outstanding_total_count=Count(
"id",
filter=Q(
requires_acknowledgement=True,
acknowledged_at__isnull=True,
),
),
outstanding_feedback_count=Count(
"id",
filter=Q(
requires_acknowledgement=True,
acknowledged_at__isnull=True,
message_type=CaseReviewMessage.MessageType.FEEDBACK,
),
),
outstanding_query_count=Count(
"id",
filter=Q(
requires_acknowledgement=True,
acknowledged_at__isnull=True,
message_type=CaseReviewMessage.MessageType.QUERY,
),
),
)
)
outstanding_case_count = 0
total_outstanding_feedback = 0
for row in message_rows:
outstanding_total_count = row["outstanding_total_count"]
outstanding_feedback_count = row["outstanding_feedback_count"]
outstanding_query_count = row["outstanding_query_count"]
stats_by_case[row["case_id"]] = {
"message_count": row["message_count"],
"outstanding_feedback_count": outstanding_feedback_count,
"outstanding_query_count": outstanding_query_count,
"outstanding_total_count": outstanding_total_count,
"has_messages": row["message_count"] > 0,
"has_outstanding_feedback": outstanding_feedback_count > 0,
"has_outstanding_query": outstanding_query_count > 0,
"has_outstanding_any": outstanding_total_count > 0,
}
if outstanding_total_count:
outstanding_case_count += 1
total_outstanding_feedback += outstanding_total_count
return stats_by_case, outstanding_case_count, total_outstanding_feedback
def _build_collection_feedback_rows(
*,
collection: CaseCollection,
cid_user_exam: CidUserExam,
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),
)
rows = []
for casedetail in casedetails:
case_stats = stats_by_case.get(casedetail.case_id, {})
rows.append(
{
"casedetail": casedetail,
"stats": case_stats,
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": cid_user_exam.pk,
"case_id": casedetail.case_id,
},
),
"cid": cid,
}
)
return rows, outstanding_case_count, total_outstanding_feedback
@user_is_collection_author_or_atlas_editor
def collection_history_user(request, exam_id: int, user_pk: int):
collection = get_object_or_404(CaseCollection, pk=exam_id)
user = get_object_or_404(User, pk=user_pk)
cid_user_exam = collection.get_or_create_cid_user_exam(user_user=user)
casedetails = CaseDetail.objects.filter(
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_by_case = {}
for review in reviews:
reviews_by_case.setdefault(review.case_id, []).append(review)
history_rows = []
answer_count = 0
case_review_stats, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
for casedetail in casedetails:
user_answer = casedetail.get_user_answers(user)
if user_answer and (user_answer.answer or user_answer.json_answer):
answer_count += 1
history_rows.append(
{
"casedetail": casedetail,
"user_answer": user_answer,
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": cid_user_exam.pk,
"case_id": casedetail.case_id,
},
),
}
)
return render(
request,
"atlas/collection_history_user.html",
{
"collection": collection,
"user": user,
"cid_user_exam": cid_user_exam,
"history_rows": history_rows,
"answer_count": answer_count,
"collection_length": len(history_rows),
},
)
@user_is_collection_author_or_atlas_editor
def collection_history_ciduser(request, exam_id: int, cid: int):
collection = get_object_or_404(CaseCollection, pk=exam_id)
cid_user_exam = collection.get_or_create_cid_user_exam(cid=cid)
casedetails = CaseDetail.objects.filter(
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_by_case = {}
for review in reviews:
reviews_by_case.setdefault(review.case_id, []).append(review)
history_rows = []
answer_count = 0
case_review_stats, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
for casedetail in casedetails:
user_answer = casedetail.get_cid_answers(cid)
if user_answer and (user_answer.answer or user_answer.json_answer):
answer_count += 1
history_rows.append(
{
"casedetail": casedetail,
"user_answer": user_answer,
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": cid_user_exam.pk,
"case_id": casedetail.case_id,
},
),
"cid": cid,
}
)
return render(
request,
"atlas/collection_history_user.html",
{
"collection": collection,
"user": cid,
"cid_user_exam": cid_user_exam,
"history_rows": history_rows,
"answer_count": answer_count,
"collection_length": len(history_rows),
"cid": cid,
},
)
@user_is_collection_author_or_atlas_editor
def collection_user_messages(request, exam_id: int, user_pk: int):
"""Supervisor page showing case conversations for one user attempt.
Outstanding items are prioritized. Full historical threads can be loaded on
demand from the same page.
"""
collection = get_object_or_404(CaseCollection, pk=exam_id)
_enforce_collection_case_query_messaging_enabled(collection)
user = get_object_or_404(User, pk=user_pk)
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")
)
feedback_rows, _, _ = _build_collection_feedback_rows(
collection=collection,
cid_user_exam=cid_user_exam,
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)
show_all = request.GET.get("show") == "all"
return render(
request,
"atlas/collection_user_messages.html",
{
"collection": collection,
"target_label": user.get_full_name() or user.username,
"target_user": user,
"cid_user_exam": cid_user_exam,
"outstanding_rows": outstanding_rows,
"history_rows": history_rows,
"total_outstanding_messages": total_outstanding_messages,
"show_all": show_all,
"cid": None,
"passcode": None,
},
)
@user_is_collection_author_or_atlas_editor
def collection_cid_messages(request, exam_id: int, cid: int):
"""Supervisor page showing case conversations for one CID attempt."""
collection = get_object_or_404(CaseCollection, pk=exam_id)
_enforce_collection_case_query_messaging_enabled(collection)
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")
)
feedback_rows, _, _ = _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_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(
request,
"atlas/collection_user_messages.html",
{
"collection": collection,
"target_label": f"CID {cid}",
"target_cid": cid,
"cid_user_exam": cid_user_exam,
"outstanding_rows": outstanding_rows,
"history_rows": history_rows,
"total_outstanding_messages": total_outstanding_messages,
"show_all": show_all,
"cid": cid,
"passcode": None,
},
)
@login_required
def user_messages_inbox(request):
"""Generic message inbox for authenticated users.
Shows outstanding acknowledgement items and allows loading full message
history across all accessible collection attempts.
"""
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"
)
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))
if authored_ids:
moderator_attempts = list(base_attempts.filter(object_id__in=authored_ids))
attempts_map = {}
for attempt in learner_attempts:
attempts_map[attempt.pk] = {"attempt": attempt, "viewer_role": "learner"}
for attempt in moderator_attempts:
attempts_map[attempt.pk] = {"attempt": attempt, "viewer_role": "moderator"}
if not attempts_map:
return render(
request,
"atlas/user_messages_inbox.html",
{
"inbox_rows": [],
"total_outstanding": 0,
"total_needs_your_ack": 0,
"total_waiting_on_other": 0,
"show_all": request.GET.get("show") == "all",
},
)
attempts = [item["attempt"] for item in attempts_map.values()]
collection_ids = sorted({attempt.object_id for attempt in attempts})
collections_by_id = CaseCollection.objects.in_bulk(collection_ids)
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)
}
attempts = [attempt for attempt in attempts if attempt.object_id in enabled_collection_ids]
if not attempts:
return render(
request,
"atlas/user_messages_inbox.html",
{
"inbox_rows": [],
"total_outstanding": 0,
"total_needs_your_ack": 0,
"total_waiting_on_other": 0,
"show_all": request.GET.get("show") == "all",
},
)
attempt_ids = [attempt.pk for attempt in attempts]
collection_ids = sorted(enabled_collection_ids)
casedetails_by_collection = defaultdict(list)
all_case_ids = set()
for casedetail in (
CaseDetail.objects.filter(collection_id__in=collection_ids)
.select_related("case")
.order_by("collection_id", "sort_order")
):
casedetails_by_collection[casedetail.collection_id].append(casedetail)
all_case_ids.add(casedetail.case_id)
message_stats_by_attempt_case = defaultdict(dict)
if all_case_ids:
for row in (
CaseReviewMessage.objects.filter(
cid_user_exam_id__in=attempt_ids,
case_id__in=all_case_ids,
)
.values("cid_user_exam_id", "case_id")
.annotate(
message_count=Count("id"),
outstanding_total_count=Count(
"id",
filter=Q(
requires_acknowledgement=True,
acknowledged_at__isnull=True,
),
),
outstanding_query_count=Count(
"id",
filter=Q(
requires_acknowledgement=True,
acknowledged_at__isnull=True,
message_type=CaseReviewMessage.MessageType.QUERY,
),
),
)
):
outstanding_total_count = row["outstanding_total_count"]
outstanding_query_count = row["outstanding_query_count"]
message_stats_by_attempt_case[row["cid_user_exam_id"]][row["case_id"]] = {
"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,
"has_messages": row["message_count"] > 0,
"has_outstanding_any": outstanding_total_count > 0,
}
inbox_rows = []
for item in attempts_map.values():
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):
continue
casedetails = casedetails_by_collection.get(collection.pk, [])
if not casedetails:
continue
attempt_stats = message_stats_by_attempt_case.get(attempt.pk, {})
feedback_rows = []
for casedetail in casedetails:
stats = attempt_stats.get(
casedetail.case_id,
{
"message_count": 0,
"outstanding_total_count": 0,
"outstanding_query_count": 0,
"outstanding_feedback_count": 0,
"has_messages": False,
"has_outstanding_any": False,
},
)
outstanding_query_count = stats.get("outstanding_query_count", 0)
outstanding_feedback_count = stats.get("outstanding_feedback_count", 0)
if viewer_role == "moderator":
needs_your_ack_count = outstanding_query_count
waiting_on_other_count = outstanding_feedback_count
else:
needs_your_ack_count = outstanding_feedback_count
waiting_on_other_count = outstanding_query_count
enriched_stats = {
**stats,
"needs_your_ack_count": needs_your_ack_count,
"waiting_on_other_count": waiting_on_other_count,
}
feedback_rows.append(
{
"casedetail": casedetail,
"stats": enriched_stats,
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": attempt.pk,
"case_id": casedetail.case_id,
},
),
"cid": attempt.cid_user.cid if attempt.cid_user_id else None,
}
)
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 not outstanding_rows and not history_rows:
continue
target_label = attempt.get_user_name()
if attempt.user_user_id:
detail_url = reverse(
"atlas:collection_user_messages",
kwargs={"exam_id": collection.pk, "user_pk": attempt.user_user_id},
)
else:
detail_url = reverse(
"atlas:collection_cid_messages",
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)
inbox_rows.append(
{
"collection": collection,
"attempt": attempt,
"target_label": target_label,
"viewer_role": viewer_role,
"detail_url": detail_url,
"outstanding_rows": outstanding_rows,
"history_rows": history_rows,
"outstanding_count": outstanding_count,
"needs_your_ack_count": needs_your_ack_count,
"waiting_on_other_count": waiting_on_other_count,
}
)
inbox_rows.sort(
key=lambda row: (
row["outstanding_count"] > 0,
row["outstanding_count"],
len(row["history_rows"]),
),
reverse=True,
)
total_outstanding = sum(row["outstanding_count"] for row in inbox_rows)
total_needs_your_ack = sum(row["needs_your_ack_count"] for row in inbox_rows)
total_waiting_on_other = sum(row["waiting_on_other_count"] for row in inbox_rows)
show_all = request.GET.get("show") == "all"
return render(
request,
"atlas/user_messages_inbox.html",
{
"inbox_rows": inbox_rows,
"total_outstanding": total_outstanding,
"total_needs_your_ack": total_needs_your_ack,
"total_waiting_on_other": total_waiting_on_other,
"show_all": show_all,
},
)
def _user_can_moderate_collection(collection: CaseCollection, user: User) -> bool:
if not getattr(user, "is_authenticated", False):
return False
return (
user.is_superuser
or user.groups.filter(name="atlas_editor").exists()
or collection.author.filter(pk=user.pk).exists()
)
def _collection_case_query_messaging_enabled(collection: CaseCollection) -> bool:
return bool(getattr(collection, "case_query_messaging_enabled", True))
def _enforce_collection_case_query_messaging_enabled(collection: CaseCollection):
if not _collection_case_query_messaging_enabled(collection):
raise Http404("Case query and messaging are disabled for this collection")
def _get_case_review_thread_context(
*,
collection: CaseCollection,
cid_user_exam: CidUserExam,
case: Case,
request,
cid: int | None = None,
passcode: str | None = None,
):
can_feedback = _user_can_moderate_collection(collection, request.user)
can_query = False
can_acknowledge_feedback = False
can_acknowledge_query = False
if not can_feedback:
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
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
except Exception:
can_query = False
can_acknowledge_feedback = False
if can_feedback:
can_acknowledge_query = True
messages = list(
CaseReviewMessage.objects.filter(cid_user_exam=cid_user_exam, case=case)
.select_related("author")
.order_by("created_at")
)
supervisor_form = None
if can_feedback:
supervisor_form = CaseReviewMessageForm(
allow_ack=True,
message_placeholder="Give case-specific feedback or reply to a learner query...",
initial={"requires_acknowledgement": True},
)
query_form = None
if can_query:
query_form = CaseReviewMessageForm(
allow_ack=False,
message_placeholder="Ask a question or raise a query about this case...",
)
unacknowledged_count = sum(
1 for message in messages if message.requires_acknowledgement and not message.is_acknowledged
)
if can_feedback:
pending_for_viewer_count = sum(
1
for message in messages
if message.requires_acknowledgement
and not message.is_acknowledged
and message.message_type == CaseReviewMessage.MessageType.QUERY
)
else:
pending_for_viewer_count = sum(
1
for message in messages
if message.requires_acknowledgement
and not message.is_acknowledged
and message.message_type != CaseReviewMessage.MessageType.QUERY
)
return {
"collection": collection,
"cid_user_exam": cid_user_exam,
"case": case,
"messages": messages,
"can_feedback": can_feedback,
"can_query": can_query,
"can_acknowledge_feedback": can_acknowledge_feedback,
"can_acknowledge_query": can_acknowledge_query,
"supervisor_form": supervisor_form,
"query_form": query_form,
"thread_url": reverse(
"atlas:collection_case_review_thread",
kwargs={
"exam_id": collection.pk,
"cid_user_exam_id": cid_user_exam.pk,
"case_id": case.pk,
},
),
"thread_dom_id": f"case-review-thread-{cid_user_exam.pk}-{case.pk}",
"cid": cid,
"passcode": passcode,
"unacknowledged_count": unacknowledged_count,
"pending_for_viewer_count": pending_for_viewer_count,
}
@require_http_methods(["GET", "POST"])
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
acknowledgement of flagged feedback. All POSTs return the refreshed HTMX
fragment for the same case thread.
"""
collection = get_object_or_404(CaseCollection, pk=exam_id)
_enforce_collection_case_query_messaging_enabled(collection)
collection_content_type = ContentType.objects.get_for_model(collection)
cid_user_exam = get_object_or_404(
CidUserExam,
pk=cid_user_exam_id,
content_type=collection_content_type,
object_id=collection.pk,
)
case = get_object_or_404(Case, pk=case_id)
if not CaseDetail.objects.filter(collection=collection, case=case).exists():
raise Http404("Case not found in collection")
cid = request.POST.get("cid") or request.GET.get("cid")
passcode = request.POST.get("passcode") or request.GET.get("passcode")
can_feedback = _user_can_moderate_collection(collection, request.user)
can_query = False
if not can_feedback:
# Check if request.user is the supervisor of the attempt owner and sharing is enabled
is_supervisor = False
if request.user.is_authenticated and cid_user_exam.user_user_id:
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:
is_supervisor = True
except Exception:
pass
if is_supervisor:
can_feedback = True
else:
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
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:
can_query = False
if not (can_feedback or can_query):
raise PermissionDenied
if request.method == "POST":
action = request.POST.get("action")
if action == "add_message":
if can_feedback:
form = CaseReviewMessageForm(
request.POST,
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)
if message_type not in {
CaseReviewMessage.MessageType.FEEDBACK,
CaseReviewMessage.MessageType.REPLY,
}:
message_type = CaseReviewMessage.MessageType.FEEDBACK
else:
form = CaseReviewMessageForm(
request.POST,
allow_ack=False,
message_placeholder="Ask a question or raise a query about this case...",
)
message_type = CaseReviewMessage.MessageType.QUERY
if form.is_valid():
message = form.save(commit=False)
message.cid_user_exam = cid_user_exam
message.case = case
if request.user.is_authenticated:
message.author = request.user
message.author_label = ""
else:
message.author = None
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.
message.requires_acknowledgement = True
elif message_type not in {
CaseReviewMessage.MessageType.FEEDBACK,
CaseReviewMessage.MessageType.REPLY,
}:
message.requires_acknowledgement = False
message.save()
elif action == "acknowledge":
message = get_object_or_404(
CaseReviewMessage,
pk=request.POST.get("message_id"),
cid_user_exam=cid_user_exam,
case=case,
)
if can_feedback and message.message_type != CaseReviewMessage.MessageType.QUERY:
raise PermissionDenied
if can_query and message.message_type == CaseReviewMessage.MessageType.QUERY:
raise PermissionDenied
if not (can_feedback or can_query):
raise PermissionDenied
if message.requires_acknowledgement:
message.acknowledge()
context = _get_case_review_thread_context(
collection=collection,
cid_user_exam=cid_user_exam,
case=case,
request=request,
cid=int(cid) if cid and str(cid).isdigit() else None,
passcode=passcode,
)
return render(request, "atlas/partials/_case_review_thread.html", context)
def collection_take_start(request, pk, cid=None, passcode=None):
"""The starting page for taking / reviewing a case collection
Args:
request (_type_): _description_
pk (_type_): _description_
Returns:
_type_: _description_
"""
collection = get_object_or_404(CaseCollection, pk=pk)
try:
collection.check_user_can_take(cid, passcode, request.user)
valid_user = True
except Http404:
valid_user = False
except collection.InactiveException as e:
return HttpResponse(f"<h1>{str(e)}</h1>")
except PrerequisiteRequired as e:
# Show a friendly page linking to the required collection rather than 404
prereq = getattr(e, 'prereq', None)
return render(
request,
'atlas/prerequisite_required.html',
{'message': str(e), 'prereq': prereq, 'cid': cid, 'passcode': passcode},
)
#valid_user = collection.check_logged_in_user(request)
cid_exam = None
if collection.collection_type == "REV" or valid_user:
cid_exam = collection.get_cid_and_user_exams(user_user=request.user).first()
# Build a lightweight summary of who results will be shared with.
sharing_summary = _collection_sharing_summary(collection, request.user)
template_variables = {
"collection": collection,
"valid_user": valid_user,
"cid_exam": cid_exam,
"sharing_summary": sharing_summary,
"start_screen_resources": collection.start_screen_resources.all(),
}
match collection.collection_type:
case x if x in ("REP", "QUE"):
return render(
request, "atlas/collection_take_start.html", template_variables
)
case "REV":
return render(
request, "atlas/collection_review_start.html", template_variables
)
case _:
return HttpResponse(f"Unknown collection type {collection.collection_type}")
def _case_number_for_collection_and_caseid(collection_pk, case_id):
"""Helper: given a collection PK and a case PK, return the case index (case_number).
Raises Http404 if the case is not found in the collection.
"""
collection = get_object_or_404(CaseCollection, pk=collection_pk)
try:
# attempt to resolve the case object (may raise Http404)
case_obj = get_case_for_case_detail(case_id)
except Http404:
raise
try:
return collection.get_index_of_case(case_obj)
except ValueError:
raise Http404(f"Case {case_id} not in collection {collection_pk}")
@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)
@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)
@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)
@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)
@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)
@login_required
def redirect_collection_case_view_by_id(request, pk, case_id):
case_number = _case_number_for_collection_and_caseid(pk, case_id)
return redirect("atlas:collection_case_view", pk=pk, case_number=case_number)
@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)
@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)
@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)
@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)
@user_is_collection_author_or_atlas_editor
def collection_case_priors(request, exam_id, case_number):
collection = get_object_or_404(CaseCollection, pk=exam_id)
try:
case_obj = collection.get_case_by_index(case_number)
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
def _render_prior_card_response(prior_case, added, relation, visibility, relation_type, error=None):
html = render_to_string(
"atlas/partials/_prior_card.html",
{
"case": prior_case,
"added": added,
"relation": relation,
"visibility": visibility,
"relation_type": relation_type,
"casedetail": casedetail,
"collection": collection,
"case_number": case_number,
"error": error,
},
request=request,
)
return HttpResponse(html)
if request.htmx:
# Ensure we can render the updated single-card partial and return it so HTMX
# can swap the card on the client side.
if "remove" in request.POST:
prior_pk = request.POST["remove"]
prior_case = Case.objects.filter(pk=prior_pk).first()
if prior_case is None:
return HttpResponse("Prior case not found", status=404)
try:
p = CasePrior.objects.get(casedetail=casedetail, prior_case=prior_pk)
p.delete()
except CasePrior.DoesNotExist:
# Already removed; continue and render the not-added card
pass
added = False
relation = ""
visibility = "AL"
if prior_case in casedetail.case.get_all_prior_cases():
rel_type = CasePrior.RelationType.PRIOR
elif prior_case in casedetail.case.get_all_future_cases():
rel_type = CasePrior.RelationType.FUTURE
else:
rel_type = CasePrior.RelationType.PRIOR
return _render_prior_card_response(
prior_case,
added=added,
relation=relation,
visibility=visibility,
relation_type=rel_type,
)
elif "prior_case_id" in request.POST:
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)
if prior_case in casedetail.case.get_all_prior_cases():
rel_type = CasePrior.RelationType.PRIOR
elif prior_case in casedetail.case.get_all_future_cases():
rel_type = CasePrior.RelationType.FUTURE
else:
rel_type = CasePrior.RelationType.PRIOR
visibility = request.POST.get("prior_visibility", "AL")
if visibility not in {
CasePrior.PriorVisibility.ALWAYS,
CasePrior.PriorVisibility.REVIEW,
CasePrior.PriorVisibility.NONE,
}:
visibility = CasePrior.PriorVisibility.ALWAYS
relation = (request.POST.get("relation") or "").strip()
use_study_date_relation = request.POST.get("use_study_date_relation") == "1"
if use_study_date_relation:
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"
else:
return _render_prior_card_response(
prior_case,
added=False,
relation=relation,
visibility=visibility,
relation_type=rel_type,
error="Study-date relation requires both this case and the prior case to have study dates.",
)
if not relation:
# Return the card partial with an inline error so HTMX will replace
# the card and display the validation message rather than navigating
# to a new page or showing a plain text response.
return _render_prior_card_response(
prior_case,
added=False,
relation=relation,
visibility=visibility,
relation_type=rel_type,
error="Enter relation text, or use study-date relation.",
)
p, created = CasePrior.objects.get_or_create(
casedetail=casedetail, prior_case=prior_case
)
p.relation_text = relation
p.prior_visibility = visibility
p.relation_type = rel_type
p.save()
added = True
relation = p.relation_text
visibility = p.prior_visibility
return _render_prior_card_response(
prior_case,
added=added,
relation=relation,
visibility=visibility,
relation_type=rel_type,
)
else:
return HttpResponse("False", status=400)
collection = casedetail.collection
added_priors = CasePrior.objects.filter(casedetail=casedetail)
added_relation_map = {}
for prior in added_priors:
added_relation_map[prior.prior_case] = (prior.relation_text, prior.prior_visibility, prior.relation_type)
case = casedetail.case
available_prior_cases = case.get_all_prior_cases()
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]))
else:
available_priors.append((c, False, "", "AL", CasePrior.RelationType.PRIOR))
available_future_cases = case.get_all_future_cases()
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]))
else:
available_futures.append((c, False, "", "AL", CasePrior.RelationType.FUTURE))
form = PriorCaseForm()
case_number, case_count = collection.get_index_of_case(
casedetail.case, case_count=True
)
previous = collection.get_previous_case(casedetail.case)
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()
return render(
request,
"atlas/collection_case_priors.html",
{
"casedetail": casedetail,
"form": form,
#"model_form": model_form,
# "example_form": example_form,
"collection": collection,
"case": casedetail.case,
"previous": previous,
"next": next,
"collection_length": case_count,
"case_number": case_number,
"added_priors": added_priors,
"available_priors": available_priors,
"available_futures": available_futures,
"can_edit": can_edit,
# Provide a default `error` value so `{% if error %}` checks in included
# partials won't raise during variable resolution when the key is absent.
"error": None,
},
)
@user_is_collection_author_or_atlas_editor
def collection_case_questions(request, exam_id, case_number):
collection = get_object_or_404(CaseCollection, pk=exam_id)
try:
case_obj = collection.get_case_by_index(case_number)
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
if not collection.collection_type == "QUE":
raise Http404("Collection not in question mode")
if request.method == "POST":
# Called if the user saves the correct answer form
if request.POST.get("submit") == "answer":
# answers = request.POST
casedetail.question_answers = json.loads(request.POST.get("json_answer"))
casedetail.save()
example_form = JsonAnswerForm(
request.POST, question_schema=casedetail.question_schema
)
form = CaseQuestionForm(instance=casedetail)
# Called if the user saves the main form
elif request.POST.get("submit") == "save":
form = CaseQuestionForm(request.POST, instance=casedetail)
if form.is_valid():
form.save()
# Add any additional logic or redirection here
# example_form = JsonAnswerForm(request.POST, question_schema=casedetail.question_schema)
post_data = request.POST.copy()
if (
casedetail.question_schema is not None
and casedetail.question_answers is not None
):
question_keys = set(casedetail.question_schema["properties"].keys())
answer_keys = set(casedetail.question_answers.keys())
answers = {}
for key, value in casedetail.question_answers.items():
if key in question_keys:
answers[key] = value
diff = question_keys - answer_keys
for key in diff:
answers[key] = ""
post_data["json_answer"] = json.dumps(answers)
casedetail.question_answers = answers
casedetail.save()
example_form = JsonAnswerForm(
post_data, question_schema=casedetail.question_schema
)
form = CaseQuestionForm(instance=casedetail)
# This shouldn't happen
else:
assert False
form = CaseQuestionForm(request.POST, instance=casedetail)
else:
post_data = request.POST.copy()
post_data["json_answer"] = json.dumps(casedetail.question_answers)
example_form = JsonAnswerForm(
post_data, question_schema=casedetail.question_schema
)
form = CaseQuestionForm(instance=casedetail)
pass
# post_data = None
# if casedetail.question_schema is not None and casedetail.question_answers is not None:
# question_keys = set(casedetail.question_schema["properties"].keys())
# answer_keys = set(casedetail.question_answers.keys())
# answers = {}
# for key, value in casedetail.question_answers.items():
# if key in question_keys:
# answers[key] = value
# diff = question_keys - answer_keys
# for key in diff:
# answers[key] = ""
# post_data = request.POST.copy()
# post_data["json_answer"]= json.dumps(answers)
# casedetail.question_answers = answers
# example_form = JsonAnswerForm(post_data, question_schema=casedetail.question_schema)
# form = CaseQuestionForm(instance=casedetail)
# blank_form = JsonAnswerForm(question_schema=casedetail.question_schema)
case_number, case_count = collection.get_index_of_case(
casedetail.case, case_count=True
)
previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case)
return render(
request,
"atlas/collection_case_questions.html",
{
"casedetail": casedetail,
"form": form,
"example_form": example_form,
"collection": collection,
"case": casedetail.case,
"previous": previous,
"next": next,
"collection_length": case_count,
"case_number": 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).
collection = get_object_or_404(CaseCollection, pk=exam_id)
try:
case_obj = collection.get_case_by_index(int(case_number))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
ordered_series = list(casedetail.case.get_ordered_series())
visibility_map = casedetail.get_series_visibility_map()
visibility_choices = [
(CaseDetail.SeriesVisibility.ALWAYS, "Show all the time"),
(CaseDetail.SeriesVisibility.REVIEW, "Show on review"),
(CaseDetail.SeriesVisibility.HIDDEN, "Do not show"),
]
if request.method == "POST":
form = CaseDetailForm(request.POST, instance=casedetail)
if form.is_valid():
casedetail = form.save(commit=False)
series_visibility_config = {}
for series in ordered_series:
field_name = f"series_visibility_{series.pk}"
visibility_value = request.POST.get(
field_name, CaseDetail.SeriesVisibility.ALWAYS
)
if visibility_value not in {
CaseDetail.SeriesVisibility.ALWAYS,
CaseDetail.SeriesVisibility.REVIEW,
CaseDetail.SeriesVisibility.HIDDEN,
}:
visibility_value = CaseDetail.SeriesVisibility.ALWAYS
series_visibility_config[str(series.pk)] = visibility_value
casedetail.series_visibility_config = series_visibility_config
casedetail.save()
if request.htmx:
return HttpResponse("Saved")
return redirect(request.path)
else:
if request.htmx:
return HttpResponse("Invalid form", status=400)
else:
form = CaseDetailForm(instance=casedetail)
series_visibility_rows = []
for series in ordered_series:
series_visibility_rows.append(
{
"series": series,
"value": visibility_map.get(
str(series.pk), CaseDetail.SeriesVisibility.ALWAYS
),
}
)
case_number, case_count = collection.get_index_of_case(
casedetail.case, case_count=True
)
previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case)
return render(
request,
"atlas/collection_case_details.html",
{
"casedetail": casedetail,
"form": form,
"visibility_choices": visibility_choices,
"series_visibility_rows": series_visibility_rows,
"collection": collection,
"case": casedetail.case,
"previous": previous,
"next": next,
"collection_length": case_count,
"case_number": case_number,
},
)
@user_is_collection_author_or_atlas_editor
def collection_mark_overview(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk)
if collection.collection_type == "REV":
raise Http404("Collection in review mode")
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection
).prefetch_related("case")
answers = CidReportAnswer.objects.filter(question__in=casedetails)
case_map = {}
for cd in casedetails:
case_map[cd] = []
for answer in answers:
case_map[answer.question].append(answer)
return render(
request,
"atlas/collection_mark_overview.html",
{
"collection": collection,
"answers": answers,
"casedetails": casedetails,
"case_map": case_map,
},
)
@user_is_collection_author_or_atlas_editor
def collection_mark_question(request, pk, case_number):
collection = get_object_or_404(CaseCollection, pk=pk)
if collection.collection_type == "REV":
raise Http404("Collection in review mode")
case, case_count = collection.get_case_by_index(case_number, case_count=True)
previous = case_number > 0
next = case_number < (case_count - 1)
casedetail_qs = CaseDetail.objects.filter(case=case, collection=collection)
casedetail = casedetail_qs.first()
answers = CidReportAnswer.objects.filter(question__in=casedetail_qs)
answer_forms = []
if request.method == "POST":
for answer in answers:
if answer:
form = CidReportAnswerMarkForm(
request.POST, instance=answer, prefix=answer.id
)
else:
form = CidReportAnswerMarkForm(request.POST, prefix=answer.id)
if form.is_valid():
answer = form.save(commit=False)
# answer.cid = cid
# answer.question = casedetail
# answer.published_date = timezone.now()
answer.save()
kwargs = {"pk": pk}
if "save" in request.POST:
return redirect(
"atlas:collection_mark_question", case_number=case_number, **kwargs
)
if "next" in request.POST:
return redirect(
"atlas:collection_mark_question", case_number=case_number + 1, **kwargs
)
elif "previous" in request.POST:
return redirect(
"atlas:collection_mark_question", case_number=case_number - 1, **kwargs
)
elif "goto" in request.POST:
return redirect(
"atlas:collection_mark_question",
case_number=int(request.POST.get("goto")),
**kwargs,
)
else:
for answer in answers:
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 []
return render(
request,
"atlas/collection_mark_question.html",
{
"collection": collection,
"series_list": series_list,
"answers": answers,
"casedetail": casedetail,
"case": case,
"case_number": case_number,
"answer_forms": answer_forms,
"previous": previous,
"next": next,
},
)
@user_is_collection_author_or_atlas_editor
def exam_cids(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk)
if (
request.user in collection.author.all()
or request.user.groups.filter(name="cid_user_manager").exists()
):
cid_users = collection.valid_cid_users.all()
cid_user_count = cid_users.count()
return render(
request,
"atlas/exam_cids.html",
{
"collection": collection,
"cid_users": cid_users,
"cid_user_count": cid_user_count,
},
)
raise PermissionDenied
def collection_take_overview_user(request, pk):
return collection_take_overview(request, pk)
@login_required
def collection_feedback_overview_user(request, pk: int):
return collection_feedback_overview(request, pk)
def collection_feedback_overview(
request, pk: int, cid: int | None = None, passcode: str | None = None
):
"""Show all case conversations for one learner attempt with outstanding items first."""
collection = get_object_or_404(CaseCollection, pk=pk)
_enforce_collection_case_query_messaging_enabled(collection)
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})
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")
)
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")]
return render(
request,
"atlas/collection_feedback_overview.html",
{
"collection": collection,
"cid": cid,
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"feedback_rows": feedback_rows,
"outstanding_rows": outstanding_rows,
"history_rows": history_rows,
"outstanding_case_count": outstanding_case_count,
"total_outstanding_feedback": total_outstanding_feedback,
},
)
def collection_take_overview(
request, pk: int, cid: int | None = None, passcode: str | None = None
):
"""
Summary page that shows which questions a user has attempted
(with / without answers)
Args:
request (_type_): _description_
pk (int): _description_
cid (int | None, optional): _description_. Defaults to None.
passcode (str | None, optional): _description_. Defaults to None.
Returns:
_type_: _description_
"""
collection = get_object_or_404(CaseCollection, pk=pk)
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})
cid_user_exam = collection.get_or_create_cid_user_exam(
cid=cid, user_user=request.user
)
if request.htmx:
if "finish" in request.POST:
cid_user_exam.completed = True
cid_user_exam.save()
collection.exam_user_status.create(
cid_user_exam=cid_user_exam,
status="submitted",
extra="manual submission",
)
return HttpResponse("True")
else:
raise Http404()
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
casedetails = list(
CaseDetail.objects.filter(case__in=cases, collection=collection).select_related("case")
)
if cid is not None:
answers = CidReportAnswer.objects.filter(cid=cid, question__in=casedetails)
else:
answers = UserReportAnswer.objects.filter(
user=request.user, question__in=casedetails
)
answer_question_map = {}
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),
)
question_answer_tuples = []
answer_count = 0
for cd in casedetails:
self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
review_stats = case_review_stats.get(cd.case_id, {})
answer_obj = answer_question_map.get(cd)
timing_state = _build_case_timing_state(collection, cd, answer_obj)
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))
answer_count += 1
else:
question_answer_tuples.append((cd, None, self_review, review_stats, timing_state))
return render(
request,
"atlas/collection_take_overview.html",
{
"collection": collection,
"cid": cid,
"question_answer_tuples": question_answer_tuples,
"answer_count": answer_count,
"collection_length": len(cases),
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"outstanding_case_count": outstanding_case_count,
"total_outstanding_feedback": total_outstanding_feedback,
"end_overview_resources": collection.end_overview_resources.all(),
},
)
def _build_case_timing_state(collection, casedetail, answer):
"""Return computed timing state for a collection case attempt.
- `effective_time_limit`: seconds until case view is locked (None means disabled)
- `effective_answer_grace_period`: extra answer-only seconds after view lock
- `case_view_locked`: user can no longer view case content/viewer
- `answer_entry_closed`: user can no longer submit/edit answer
"""
effective_time_limit = collection.get_effective_case_time_limit(casedetail)
effective_answer_grace_period = collection.get_effective_answer_entry_grace_period(
casedetail
)
if effective_time_limit is None:
return {
"effective_time_limit": None,
"effective_answer_grace_period": 0,
"case_view_locked": False,
"answer_entry_closed": False,
"elapsed_seconds": 0,
"case_view_seconds_remaining": None,
"answer_entry_seconds_remaining": None,
}
started_at = getattr(answer, "started_at", None)
elapsed_seconds = 0
if started_at is not None:
elapsed_seconds = max(0, int((timezone.now() - started_at).total_seconds()))
case_view_locked = elapsed_seconds >= effective_time_limit
answer_entry_closed = elapsed_seconds >= (
effective_time_limit + (effective_answer_grace_period or 0)
)
case_view_seconds_remaining = max(0, effective_time_limit - elapsed_seconds)
answer_entry_seconds_remaining = max(
0,
(effective_time_limit + (effective_answer_grace_period or 0)) - elapsed_seconds,
)
return {
"effective_time_limit": effective_time_limit,
"effective_answer_grace_period": effective_answer_grace_period or 0,
"case_view_locked": case_view_locked,
"answer_entry_closed": answer_entry_closed,
"elapsed_seconds": elapsed_seconds,
"case_view_seconds_remaining": case_view_seconds_remaining,
"answer_entry_seconds_remaining": answer_entry_seconds_remaining,
}
def _collection_sharing_summary(collection, user):
"""Return a dict describing who a registered user's results will be shared with
for the given collection, before an attempt is created.
Keys:
always_visible_to list of User objects (authors + markers)
supervisor User | None (linked supervisor account, if any)
supervisor_always bool (results_supervisor_visible on the collection)
supervisor_opt_in bool (user can choose to share with supervisor)
"""
always_visible = []
seen_pks = set()
for attr in ("author", "markers"):
if hasattr(collection, attr):
for u in getattr(collection, attr).select_related():
if u.pk not in seen_pks:
seen_pks.add(u.pk)
always_visible.append(u)
supervisor_always = getattr(collection, "results_supervisor_visible", False)
supervisor_user = None
supervisor_opt_in = False
if user and user.is_authenticated:
try:
sup = user.userprofile.supervisor
if sup:
supervisor_opt_in = True
if sup.user_id:
supervisor_user = sup.user
except Exception:
pass
return {
"always_visible_to": always_visible,
"supervisor": supervisor_user,
"supervisor_always": supervisor_always,
"supervisor_opt_in": supervisor_opt_in,
}
@login_required
def collection_exam_sharing_panel(request, pk):
"""HTMX view to display and manage sharing settings for a CidUserExam attempt.
Scope: attempt owner only (user_user). Read-only rendering for all others.
GET returns the sharing panel partial.
POST handles one of three actions (action= toggle_supervisor | add_user | remove_user).
"""
cid_user_exam = get_object_or_404(CidUserExam, pk=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)
if request.method == "POST":
if not is_owner:
raise PermissionDenied()
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.save(update_fields=["share_with_supervisor"])
elif action == "add_user":
try:
user_to_add = User.objects.get(pk=int(request.POST.get("user_id", "")))
if user_to_add.pk != request.user.pk:
cid_user_exam.shared_with_users.add(user_to_add)
except (User.DoesNotExist, ValueError, TypeError):
pass
elif action == "remove_user":
try:
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
sharing_info = cid_user_exam.get_sharing_info()
return render(
request,
"atlas/partials/_sharing_panel.html",
{
"cid_user_exam": cid_user_exam,
"sharing_info": sharing_info,
"is_owner": is_owner,
},
)
@login_required
def collection_exam_user_search(request, pk):
"""HTMX endpoint to search for users to add to a CidUserExam's shared_with_users.
Scope: attempt owner only.
GET param q minimum 2 characters. Returns a small result-list partial.
"""
cid_user_exam = get_object_or_404(CidUserExam, pk=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):
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))
users = list(
User.objects.filter(
Q(first_name__icontains=q)
| Q(last_name__icontains=q)
| Q(email__icontains=q)
| Q(username__icontains=q)
)
.exclude(pk=request.user.pk)
.exclude(pk__in=already_shared)
.order_by("last_name", "first_name")[:10]
)
return render(
request,
"atlas/partials/_sharing_user_search_results.html",
{
"users": users,
"cid_user_exam": cid_user_exam,
"q": q,
},
)
@login_required
def collection_shared_with_me(request):
"""Legacy redirect — the shared-with-me page is now part of the supervision page."""
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.
Accessible to authors, markers, supervisors (when sharing is enabled), and
users manually added to CidUserExam.shared_with_users.
"""
cid_user_exam = get_object_or_404(
CidUserExam.objects.select_related("user_user", "cid_user"),
pk=cid_user_exam_pk,
)
if not cid_user_exam.can_user_view(request.user):
raise PermissionDenied()
collection = cid_user_exam.exam
if not isinstance(collection, CaseCollection):
raise Http404()
casedetails = list(
CaseDetail.objects.filter(collection=collection).select_related("case").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_by_case = {}
for review in reviews:
reviews_by_case.setdefault(review.case_id, []).append(review)
case_review_stats, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=casedetails,
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
history_rows = []
answer_count = 0
for casedetail in casedetails:
if cid_user_exam.user_user_id:
user_answer = casedetail.get_user_answers(cid_user_exam.user_user)
else:
user_answer = casedetail.get_cid_answers(cid_user_exam.cid_user.cid)
if user_answer and (user_answer.answer or user_answer.json_answer):
answer_count += 1
history_rows.append(
{
"casedetail": casedetail,
"user_answer": user_answer,
"self_reviews": reviews_by_case.get(casedetail.case_id, []),
"case_review_stats": case_review_stats.get(casedetail.case_id, {}),
}
)
return render(
request,
"atlas/collection_history_user.html",
{
"collection": collection,
"user": cid_user_exam.get_user_name(),
"cid_user_exam": cid_user_exam,
"history_rows": history_rows,
"answer_count": answer_count,
"collection_length": len(history_rows),
"cid": cid_user_exam.cid_user.cid if cid_user_exam.cid_user_id else None,
"read_only_shared_view": True,
},
)
@login_required
def collection_supervision(request):
"""Supervision page: lists all collection attempts visible to the current user as
an author, marker, supervisor, or manual-share recipient.
Supports:
- GET param ``group_by``: ``user`` (default) or ``collection``
- GET params ``date_from`` / ``date_to``: ISO date strings to filter by
attempt start_time (inclusive).
- GET param ``q``: free-text search over learner name / username / email.
- GET param ``supervisor_only``: ``1`` to restrict to attempts where the current
user is the assigned supervisor (only shown when the user is a supervisor).
"""
from django.db.models import Q
from django.contrib.contenttypes.models import ContentType
from django.utils.dateparse import parse_date
collection_ct = ContentType.objects.get_for_model(CaseCollection)
# ---- determine supervisor status ----
is_supervisor = False
trainee_users = User.objects.none()
trainee_cid_users = CidUser.objects.none()
try:
supervisor = request.user.supervisor
is_supervisor = True
trainee_users = User.objects.filter(userprofile__supervisor=supervisor)
trainee_cid_users = CidUser.objects.filter(supervisor=supervisor)
except Exception:
pass
supervisor_only = request.GET.get("supervisor_only", "") == "1" and is_supervisor
# ---- build base querysets of accessible attempts ----
manual_qs = CidUserExam.objects.filter(
shared_with_users=request.user,
content_type=collection_ct,
).exclude(user_user=request.user)
managed_collection_ids = list(
CaseCollection.objects.filter(
Q(author=request.user) | Q(markers=request.user)
).values_list("pk", flat=True)
)
authored_qs = CidUserExam.objects.filter(
content_type=collection_ct,
object_id__in=managed_collection_ids,
).exclude(user_user=request.user)
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)
visible_collection_ids = list(
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)
if supervisor_only:
all_attempts = (supervisor_qs | supervisor_exam_qs).distinct()
else:
all_attempts = (
manual_qs | authored_qs | supervisor_qs | supervisor_exam_qs
).distinct()
all_attempts = all_attempts.select_related("user_user", "cid_user")
# ---- date filter ----
date_from_raw = request.GET.get("date_from", "").strip()
date_to_raw = request.GET.get("date_to", "").strip()
try:
if date_from_raw:
date_from = parse_date(date_from_raw)
if date_from:
all_attempts = all_attempts.filter(start_time__date__gte=date_from)
except Exception:
pass
try:
if date_to_raw:
date_to = parse_date(date_to_raw)
if date_to:
all_attempts = all_attempts.filter(start_time__date__lte=date_to)
except Exception:
pass
# ---- user / learner search ----
q = request.GET.get("q", "").strip()
if q:
all_attempts = all_attempts.filter(
Q(user_user__first_name__icontains=q)
| Q(user_user__last_name__icontains=q)
| Q(user_user__username__icontains=q)
| Q(user_user__email__icontains=q)
| Q(cid_user__name__icontains=q)
| Q(cid_user__email__icontains=q)
)
all_attempts = all_attempts.order_by("-start_time")
# ---- grouping ----
group_by = request.GET.get("group_by", "user")
if group_by not in ("user", "collection"):
group_by = "user"
grouped = {}
for attempt in all_attempts:
collection_obj = attempt.exam
if group_by == "collection":
key_label = str(collection_obj) if collection_obj else "Unknown collection"
key_id = getattr(collection_obj, "pk", 0)
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_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_id = f"cid_{attempt.cid_user_id}"
else:
key_label = "Unknown learner"
key_id = 0
key = (key_id, key_label)
grouped.setdefault(key, []).append((attempt, collection_obj))
groups = sorted(grouped.items(), key=lambda x: str(x[0][1]).lower())
groups = [(label, rows) for (_, label), rows in groups]
return render(
request,
"atlas/supervision.html",
{
"groups": groups,
"group_by": group_by,
"date_from": date_from_raw,
"date_to": date_to_raw,
"q": q,
"is_supervisor": is_supervisor,
"supervisor_only": supervisor_only,
"total_attempts": all_attempts.count(),
},
)
def collection_case_view_take_user_answers(request, pk: int, case_number: int):
return collection_case_view_take_answers(request, pk, case_number)
@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):
"""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
read-only capacity — loading the *target user's* saved answers and exam
state without mutating anything (no start_time, no answer creation, no POST
handling).
Access is gated by ``@user_is_collection_author_or_atlas_editor`` which also
permits an authorised supervisor when the trainee has enabled
``share_with_supervisor`` or the collection is marked
``results_supervisor_visible``.
Args:
pk: CaseCollection primary key.
case_number: Zero-based index of the case within the collection.
user_pk: Primary key of the trainee whose answers are to be viewed.
"""
from django.contrib.auth import get_user_model
User = get_user_model()
collection = get_object_or_404(CaseCollection, pk=pk)
target_user = get_object_or_404(User, pk=user_pk)
case, case_count = collection.get_case_by_index(case_number, case_count=True)
casedetail = CaseDetail.objects.get(case=case, collection=collection)
# Load the target user's exam record (read-only — no create side effects if
# 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,
object_id=collection.pk,
user_user=target_user,
).first()
# Load existing answer for this case — never create a placeholder.
answer = None
form = None
if collection.collection_type in ("REP", "QUE"):
answer = casedetail.userreportanswer_set.filter(user=target_user).first()
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
# reviewer so all post-completion sections (report, findings, discussion)
# are shown, matching what the trainee sees when reviewing their own answers.
question_completed = True
show_title = collection.show_title_post
show_history = collection.show_history_post
show_description = collection.show_description_post
show_discussion = collection.show_discussion_post
show_report = collection.show_report_post
series_list = casedetail.get_visible_case_series(review_mode=True)
prior_cases = casedetail.caseprior_set.all()
series_to_load = []
for series in series_list:
series_to_load.append((series, False, ""))
for prior in prior_cases:
match prior.prior_visibility:
case "AL":
for series in prior.prior_case.get_series():
series_to_load.append((series, True, prior.relation_text))
case "RE":
for series in prior.prior_case.get_series():
series_to_load.append((series, True, prior.relation_text))
case _:
continue
has_priors = any(prior_flag for (_, prior_flag, _) in series_to_load)
prior_count = prior_cases.count()
case_review_stats = {}
self_review = []
resources = case.caseresource_set.all()
if cid_user_exam:
case_review_stats_map, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=[casedetail],
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
case_review_stats = case_review_stats_map.get(casedetail.case_id, {})
self_review = cid_user_exam.selfreview_set.filter(case=case)
previous = case_number > 0
next_case = case_number < (case_count - 1)
return render(
request,
"atlas/collection_case_view_take.html",
{
"form": form,
"collection": collection,
"case": case,
"casedetail": casedetail,
"series_list": series_list,
"named_stacks_json": casedetail.get_case_named_stacks(review_mode=True),
"series_to_load": series_to_load,
"has_priors": has_priors,
"prior_count": prior_count,
"case_number": case_number,
"previous": previous,
"next": next_case,
"cid": None,
"passcode": None,
"answer": answer,
"show_title": show_title,
"show_history": show_history,
"show_description": show_description,
"show_discussion": show_discussion,
"show_report": show_report,
"resources": resources,
"cid_user_exam": cid_user_exam,
"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,
"effective_question_time_limit": None,
"effective_answer_entry_grace_period": None,
"case_view_locked": False,
"answer_entry_closed": False,
"start_screen_resources": collection.start_screen_resources.all(),
# Read-only mode flags
"viewing_as_user": True,
"target_user": target_user,
},
)
def collection_case_view_take_answers(
request, pk: int, case_number: int, cid=None, passcode=None
):
""" """
collection = get_object_or_404(CaseCollection, pk=pk)
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})
case, case_count = collection.get_case_by_index(case_number, case_count=True)
cid_user_exam = collection.get_or_create_cid_user_exam(
cid=cid, user_user=request.user
)
casedetail = CaseDetail.objects.get(case=case, collection=collection)
return render(
request,
"atlas/collection_case_view_take_answers.html",
{
"collection": collection,
"case": case,
"casedetail": casedetail,
},
)
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)
def collection_case_view_take(
request, pk, case_number=None, cid=None, passcode=None, case_id=None
):
"""Candidate-facing collection case taking/review screen.
This view is for end users taking a case (or reviewing their answers after
completion). It should remain answer-focused and must not expose management
edit controls for case content.
Supports either:
- ``case_number``: zero-based index within the collection
- ``case_id``: case PK (translated to the collection index)
Raises:
Http404: If access is invalid or no case is specified.
"""
collection = get_object_or_404(CaseCollection, pk=pk)
# If caller provided a case_id (case PK) instead of a case_number (index),
# translate it into the index used by collection.get_case_by_index().
if case_id is not None and case_number is None:
try:
case_obj = get_case_for_case_detail(case_id)
except Http404:
raise
try:
case_number = collection.get_index_of_case(case_obj)
except ValueError:
raise Http404(f"Case {case_id} not in collection {pk}")
# Ensure we have a case_number to work with
if case_number is None:
raise Http404("No case specified")
form = None
answer: None | CidReportAnswer | UserReportAnswer = None
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})
case, case_count = collection.get_case_by_index(case_number, case_count=True)
cid_user_exam = collection.get_or_create_cid_user_exam(
cid=cid, user_user=request.user
)
if cid_user_exam.start_time is None:
cid_user_exam.start_time = timezone.now()
cid_user_exam.save()
casedetail = CaseDetail.objects.get(case=case, collection=collection)
if not collection.review_only():
if collection.collection_type == "REP":
if cid is not None:
answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
ReportAnswerForm = CidReportAnswerForm
else:
answer = casedetail.userreportanswer_set.filter(
user=request.user
).first()
ReportAnswerForm = UserReportAnswerForm
elif collection.collection_type == "QUE":
if cid is not None:
answer = casedetail.cidreportanswer_set.filter(cid=cid).first()
ReportAnswerForm = CidQuestionAnswerForm
else:
answer = casedetail.userreportanswer_set.filter(
user=request.user
).first()
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:
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)
# 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"):
try:
if cid is not None:
answer = CidReportAnswer(question=casedetail, cid=cid)
else:
answer = UserReportAnswer(question=casedetail, user=request.user)
answer.started_at = timezone.now()
answer.save()
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)
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:
answer.completed = True
if not answer.submitted_at:
answer.submitted_at = timezone.now()
answer.save()
if request.method == "POST":
if collection.collection_type in ("REP", "QUE"):
if not collection.publish_results:
if answer:
form = ReportAnswerForm(
request.POST, instance=answer, casedetail=casedetail
)
else:
form = ReportAnswerForm(request.POST, casedetail=casedetail)
timed_out_submission = request.POST.get('timed_out') == '1'
if not cid_user_exam.completed and not (
answer is not None and answer.completed
):
if timing_state["answer_entry_closed"] and not timed_out_submission:
if answer is not None and not answer.completed:
answer.completed = True
if not answer.submitted_at:
answer.submitted_at = timezone.now()
answer.save()
is_ajax = (
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,
}
)
if form.is_valid():
answer = form.save(commit=False)
answer.set_cid_or_user(cid=cid, user=request.user)
answer.question = casedetail
# Record submission timestamp
answer.submitted_at = timezone.now()
# answer.published_date = timezone.now()
if "complete_case" in request.POST:
if not collection.self_review:
raise Http404("Self review not enabled")
answer.completed = True
# If this was a timed-out submission, mark as completed
if request.POST.get('timed_out') == '1':
answer.completed = True
## Also mark the overall exam as completed for this user/CID
#try:
# cid_user_exam.completed = True
# cid_user_exam.save()
# # Record an exam status entry
# collection.exam_user_status.create(
# cid_user_exam=cid_user_exam,
# status="submitted",
# extra="autosubmit",
# )
#except Exception:
# logger.exception("Failed to mark cid_user_exam completed on autosubmit")
answer.save()
cid_user_exam.end_time = timezone.now()
cid_user_exam.save()
# If this was an AJAX/HTMX request, return JSON so the client
# 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)
)
if is_ajax:
return JsonResponse(
{
'status': 'ok',
'locked': answer.completed,
'submitted_at': answer.submitted_at.isoformat() if getattr(answer, 'submitted_at', None) else None,
}
)
if cid is not None:
kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
redirect_url = "atlas:collection_case_view_take"
overview_url = "atlas:collection_take_overview"
else:
kwargs = {"pk": pk}
redirect_url = "atlas:collection_case_view_take_user"
overview_url = "atlas:collection_take_overview_user"
if "next" in request.POST:
return redirect(redirect_url, case_number=case_number + 1, **kwargs)
elif "previous" in request.POST:
return redirect(redirect_url, case_number=case_number - 1, **kwargs)
elif "finish" in request.POST:
return redirect(overview_url, **kwargs)
elif "goto" in request.POST:
return redirect(
redirect_url,
case_number=int(request.POST.get("goto")),
**kwargs,
)
else:
if collection.collection_type in ("REP", "QUE"):
form = ReportAnswerForm(instance=answer, casedetail=casedetail)
previous = case_number > 0
next = case_number < (case_count - 1)
# Set what details a user is / is not able to view when taking the case
# this can be configured via the collection edit menu
if (
collection.publish_results
or cid_user_exam.completed
or (
not collection.feedback_once_collection_complete
and answer is not None
and answer.completed
)
):
question_completed = True
show_title = collection.show_title_post
show_history = collection.show_history_post
show_description = collection.show_description_post
show_discussion = collection.show_discussion_post
show_report = collection.show_report_post
self_review = cid_user_exam.selfreview_set.filter(case=case)
resources = case.caseresource_set.all()
else:
question_completed = False
show_title = collection.show_title_pre
show_history = collection.show_history_pre
show_description = collection.show_description_pre
show_discussion = collection.show_discussion_pre
show_report = collection.show_report_pre
self_review = []
resources = case.caseresource_set.filter(pre_review=True)
series_list = casedetail.get_visible_case_series(review_mode=question_completed)
prior_cases = casedetail.caseprior_set.all()
series_to_load = []
for series in series_list:
series_to_load.append((series, False, ""))
for prior in prior_cases:
match prior.prior_visibility:
case "AL":
for series in prior.prior_case.get_series():
series_to_load.append((series, True, prior.relation_text))
case "RE":
if question_completed:
for series in prior.prior_case.get_series():
series_to_load.append((series, True, prior.relation_text))
case _:
continue
# Determine whether any prior series will be shown to the candidate.
has_priors = any(prior_flag for (_, prior_flag, _) in series_to_load)
prior_count = prior_cases.count()
case_review_stats, _, _ = _get_case_review_stats_for_exam(
cid_user_exam=cid_user_exam,
casedetails=[casedetail],
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"]
)
return render(
request,
"atlas/collection_case_view_take.html",
{
"form": form,
"collection": collection,
"case": case,
"casedetail": casedetail,
"series_list": series_list,
"named_stacks_json": casedetail.get_case_named_stacks(
review_mode=question_completed
),
"series_to_load": series_to_load,
"has_priors": has_priors,
"prior_count": prior_count,
"case_number": case_number,
"previous": previous,
"next": next,
"cid": cid,
"passcode": passcode,
"answer": answer,
"show_title": show_title,
"show_history": show_history,
"show_description": show_description,
"show_discussion": show_discussion,
"show_report": show_report,
"resources": resources,
"cid_user_exam": cid_user_exam,
"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,
"effective_question_time_limit": timing_state["effective_time_limit"],
"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(),
},
)
@user_is_collection_author_or_atlas_editor
def collection_case_view(request, pk, case_number=None, case_id=None):
"""Management view for a case within a collection.
This view is used by collection authors/atlas editors to manage case
content in collection context. It renders the full case detail template and
provides ``can_edit`` for inline editing widgets/partials.
Supports either:
- ``case_number``: zero-based index within the collection
- ``case_id``: case PK (translated to the collection index)
Raises:
Http404: If no valid case is specified.
"""
collection = get_object_or_404(CaseCollection, pk=pk)
# Support either case_number (index) or case_id (case PK) in the URL/kwargs.
if case_id is not None and case_number is None:
try:
case_obj = get_case_for_case_detail(case_id)
except Http404:
raise
try:
case_number = collection.get_index_of_case(case_obj)
except ValueError:
raise Http404(f"Case {case_id} not in collection {pk}")
if case_number is None:
raise Http404("No case specified")
form = None
answer = None
case, case_count = collection.get_case_by_index(case_number, case_count=True)
case = get_case_for_case_detail(case.pk)
casedetail = CaseDetail.objects.get(case=case, collection=collection)
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")
if prefetched_images is not None:
series.visible_image_count = len(prefetched_images)
else:
series.visible_image_count = series.images.filter(removed=False).count()
move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case)
move_series_recent_cases = list(
get_cases_available_to_user(request.user)
.exclude(pk=case.pk)
.filter(author=request.user)
.order_by("-created_date")
.prefetch_related("author", "condition", "presentation", "subspecialty")[:5]
)
previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case)
can_edit = case.check_user_can_edit(request.user)
return render(
request,
"atlas/case_detail.html",
{
"form": form,
"collection": collection,
"collection_length": case_count,
"case": case,
"casedetail": casedetail,
"series_list": series_list,
"ordered_series": ordered_series,
"case_number": case_number,
"previous": previous,
"next": next,
"can_edit": can_edit,
"move_series_form": move_series_form,
"move_series_recent_cases": move_series_recent_cases,
"answer": answer,
"cimar_sid": request.user.userprofile.cimar_sid,
"site_queryset": Site.objects.all(),
"subspecialty_queryset": Subspecialty.objects.all(),
},
)
def collection_dicom_json(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk)
return JsonResponse(collection.get_ohif_dicom_json())
@user_is_author_or_atlas_editor
def use_dates_as_descriptions(request, pk):
case = get_object_or_404(Case, pk=pk)
series: Series
for series in case.series.all():
series.use_date_as_description()
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)
def collection_case_dicom_json(request, exam_id, case_number, review=False):
# Support either case_number (index) or case_id (case PK)
collection = get_object_or_404(CaseCollection, pk=exam_id)
try:
case_obj = collection.get_case_by_index(int(case_number))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception:
raise Http404("Case not found in collection")
main_series = casedetail.get_visible_case_series(review_mode=review)
studies_json = {
"studies": [
{
"StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630178",
"StudyTime": "",
"PatientName": casedetail.case.title,
"PatientID": "LIDC-IDRI-0001",
"AccessionNumber": "",
"PatientAge": "",
"PatientSex": "",
"series": [series.get_series_dicom_json() for series in main_series],
}
]
}
prior_qs = casedetail.caseprior_set.all().select_related("prior_case")
for prior in prior_qs:
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review:
continue
studies_json["studies"].append(
{
"StudyInstanceUID": "1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633453246975630177",
"StudyTime": "",
"PatientName": casedetail.case.title,
"PatientID": "LIDC-IDRI-0001",
"AccessionNumber": "",
"PatientAge": "",
"PatientSex": "",
"series": [
series.get_series_dicom_json()
for series in prior.prior_case.get_ordered_series()
],
"StudyDescription": f"{'Prior' if prior.relation_type == CasePrior.RelationType.PRIOR else 'Future'}: {prior.relation_text}",
}
)
return JsonResponse(studies_json)
def case_dicom_json(request, pk):
case = get_object_or_404(Case, pk=pk)
return JsonResponse(case.get_case_dicom_json())
@login_required
def resource_search_widget(request):
"""HTMX endpoint: search existing `Resource` objects with simple filters.
GET params:
- q: query string (name/description)
- site: site id (optional)
- subspecialty: subspecialty id (optional)
- open_access: '1' or '0' (optional)
Returns rendered partial `atlas/partials/resource_search_widget_results.html`.
"""
q = request.GET.get("q", "").strip()
site = request.GET.get("site")
subspecialty = request.GET.get("subspecialty")
open_access = request.GET.get("open_access")
qs = Resource.objects.all()
if 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))
except Exception:
pass
if subspecialty:
try:
qs = qs.filter(subspecialty__id=int(subspecialty))
except Exception:
pass
if open_access in ["0", "1"]:
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()):
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})
@login_required
def resource_search_panel(request):
"""HTMX endpoint: return the full resource search panel HTML fragment.
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(),
})
@login_required
@require_POST
def case_attach_resource(request, pk):
"""Attach an existing resource to the case (HTMX POST).
POST params:
- resource_id: integer
- pre_review: '1' or '0' (optional)
Returns the updated `atlas/partials/case_resources_list.html` fragment.
"""
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
pre_review = request.POST.get("pre_review", "0") == "1"
try:
resource = Resource.objects.get(pk=int(resource_id))
except Exception:
return HttpResponse(status=400)
# create if not exists
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
cr.save()
# Refresh case from DB to ensure related sets reflect changes and render updated list
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
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}})
return resp
@login_required
@require_POST
def case_detach_resource(request, pk):
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
try:
cr = CaseResource.objects.filter(case=case, resource__id=int(resource_id))
cr.delete()
except Exception:
pass
# Refresh case from DB and return updated partial
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
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
@login_required
@require_POST
def case_toggle_resource_pre(request, pk):
"""Toggle the pre_review flag on a CaseResource (HTMX POST).
POST params:
- resource_id: integer
Returns the updated `atlas/partials/case_resources_list.html` fragment.
"""
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
try:
cr = CaseResource.objects.get(case=case, resource__id=int(resource_id))
except Exception:
return HttpResponse(status=400)
cr.pre_review = not bool(cr.pre_review)
cr.save()
# Refresh case from DB to ensure related sets reflect changes and render updated list
try:
case = Case.objects.get(pk=case.pk)
except Exception:
pass
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
def series_dicom_json(request, pk):
series = get_object_or_404(Series, pk=pk)
return JsonResponse(series.get_ohif_dicom_json())
def series_tags_partial(request, pk):
logger.info(f"Fetching tags for series {pk}")
# Accept either a numeric PK or a SeriesInstanceUID string
series = None
try:
if str(pk).isdigit():
series = Series.objects.filter(pk=int(pk)).first()
except Exception:
series = None
if series is None:
# Try exact match first
series = Series.objects.filter(series_instance_uid=pk).first()
if series is None:
# Try URL-decoded value and stripped variants
try:
import urllib.parse
decoded = urllib.parse.unquote(pk)
except Exception:
decoded = pk
if decoded != pk:
series = Series.objects.filter(series_instance_uid=decoded).first()
if series is None:
# Fallback to contains (be permissive for slightly mangled UIDs)
try:
series = Series.objects.filter(series_instance_uid__contains=pk).first()
except Exception:
series = None
images = None
if series is not None:
images = list(series.images.all())
else:
# If no Series exists yet, try to find uploaded uncategorised dicoms
try:
dicoms = UncategorisedDicom.objects.filter(series_instance_uid=pk)
except Exception:
dicoms = UncategorisedDicom.objects.none()
if not dicoms.exists():
try:
import urllib.parse
decoded = urllib.parse.unquote(pk)
except Exception:
decoded = pk
if decoded != pk:
dicoms = UncategorisedDicom.objects.filter(series_instance_uid=decoded)
if not dicoms.exists():
try:
dicoms = UncategorisedDicom.objects.filter(series_instance_uid__contains=pk)
except Exception:
dicoms = UncategorisedDicom.objects.none()
if not dicoms.exists():
return HttpResponse(status=404)
images = list(dicoms)
num_images = len(images)
tag_values = {}
tag_counts = {}
for img in images:
# Uploaded uncategorised dicoms use a different API than SeriesImage
try:
if isinstance(img, UncategorisedDicom):
try:
ds = pydicom.dcmread(img.image)
except Exception:
# skip invalid dicoms
continue
else:
ds = img.get_dicom_data()
except Exception:
# Defensive: skip items that don't expose expected methods
continue
if not ds:
continue
# Iterate elements
for elem in ds:
kw = getattr(elem, "keyword", None) or str(elem.tag)
try:
val = elem.value
except Exception:
val = None
if isinstance(val, pydicom.multival.MultiValue):
sval = ",".join(map(str, val))
else:
sval = str(val) if val is not None else ""
tag_counts.setdefault(kw, 0)
tag_counts[kw] += 1
tag_values.setdefault(kw, set()).add(sval)
common_tags = {}
for k, vals in tag_values.items():
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)
return HttpResponse(html)
@user_is_collection_author_or_atlas_editor
def delete_collection_cid_answers(request, exam_id, cid):
collection = get_object_or_404(CaseCollection, pk=exam_id)
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection
).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter(
question__in=casedetails, cid=cid
)
temp = {}
for ans in cid_user_answers:
temp[ans.pk] = (ans.question.pk, ans.score, ans.cid, ans.answer)
ans.delete()
return JsonResponse(temp)
@user_is_collection_author_or_atlas_editor
def delete_collection_answers(request, exam_id):
collection = get_object_or_404(CaseCollection, pk=exam_id)
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection
).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)
temp = {}
for ans in cid_user_answers:
temp[ans.pk] = (ans.question.pk, ans.score, ans.cid, ans.answer)
ans.delete()
return JsonResponse(temp)
# TODO: this needs a major cleanup
@user_is_collection_author_or_atlas_editor
def collection_scores_cid(request, pk):
collection = get_object_or_404(CaseCollection, pk=pk)
if not collection.exam_mode:
raise Http404("Collection not in exam mode")
user_answers_and_marks = defaultdict(list)
user_answers_marks = defaultdict(list)
user_answers = defaultdict(list)
user_names = {}
# cid_passcodes = {}
by_question = defaultdict(dict)
unmarked = set()
cases = collection.cases.all().order_by("casedetail__sort_order").prefetch_related()
casedetails = CaseDetail.objects.filter(
case__in=cases, collection=collection
).prefetch_related("case")
cid_user_answers = CidReportAnswer.objects.filter(question__in=casedetails)
cids = set()
case_list = list(cases)
# Loop through all candidates
for cid_user_answer in cid_user_answers:
# Convoluted (probably...)
cid = cid_user_answer.cid
# cid_passcodes[cid] = cid_user_answer.passcode
cids.add(cid)
s = cid_user_answer
user_names[cid] = cid
q = cid_user_answer.question.case
# if not s:
# # skip if no answer
# user_answers_marks[cid].append(0)
# user_answers[cid].append("")
# by_question[q].append(("", 0))
# continue
ans = s.answer
answer_score = s.get_answer_score()
if answer_score == "unmarked" or answer_score == None:
index = case_list.index(q)
unmarked.add(index)
user_answers[cid].append(ans)
user_answers_marks[cid].append(answer_score)
user_answers_and_marks[cid].append((ans, answer_score))
by_question[q][cid] = (ans, answer_score, s.feedback)
user_scores = {}
user_scores_normalised = {}
for user in user_answers_marks:
user_scores[user] = sum(
[i for i in user_answers_marks[user] if (i != "unmarked" and i != None)]
)
user_scores_normalised[user] = user_scores[user]
# ignore scores of 0 for stats
user_scores_list = [i for i in user_scores.values() if i > 0]
# Use collection's case_question_max_score if set, otherwise default to 10
case_max = collection.case_question_max_score or 10
max_score = len(cases) * case_max
if len(user_scores_list) < 1:
mean = 0
median = 0
mode = 0
fig_html = ""
else:
mean = statistics.mean(user_scores_list)
median = statistics.median(user_scores_list)
try:
mode = statistics.mode(user_scores_list)
except statistics.StatisticsError:
mode = "No unique mode"
df = user_scores_list
fig = px.histogram(
df,
x=0,
title="{}: distribution of scores".format(collection),
labels={"0": "Score"},
height=400,
width=600,
)
fig_html = fig.to_html()
collection.stats_mean = mean
collection.stats_median = median
collection.stats_mode = mode
collection.stats_candidates = len(user_scores_list)
collection.stats_max_possible = max_score
collection.stats_min = min(user_scores_list)
collection.stats_max = max(user_scores_list)
# collection.stats_graph = fig_html
collection.user_scores = user_scores
collection.save()
return render(
request,
f"atlas/collection_scores.html",
{
"cids": sorted(cids),
# "cid_passcodes": cid_passcodes,
"exam": collection,
"collection": collection,
"unmarked": unmarked,
"questions": cases,
"by_question": by_question,
"user_answers": dict(user_answers),
"user_answers_marks": dict(user_answers_marks),
"user_scores": user_scores,
"user_scores_normalised": user_scores_normalised,
"user_scores_list": user_scores_list,
"user_names": user_names,
"user_answers_and_marks": user_answers_and_marks,
"max_score": max_score,
"mean": mean,
"median": median,
"mode": mode,
"plot": fig_html,
},
)
GenericExamViews = ExamViews(
CaseCollection, Case, None, CidReportAnswer, "atlas", "casecollection"
)
class CollectionCloneMixin:
def get_initial(self):
old_object = get_object_or_404(self.model, pk=self.kwargs["exam_id"])
initial_data = model_to_dict(old_object, exclude=["id"])
# We manually transfer the forign keys / m2m relationships
questions = old_object.cases.all().values_list("id", flat=True)
authors = old_object.author.all().values_list("id", flat=True)
self.cases = list(questions)
self.author = list(authors)
return initial_data
def form_valid(self, form):
object = form.save()
# Reapply these otherwise they get lost?
object.cases.set(self.cases)
object.author.set(self.author)
object.save()
return HttpResponseRedirect(object.get_absolute_url())
class CollectionClone(CollectionCloneMixin, CaseCollectionCreate):
"""Clone exam view"""
class SelfReviewDelete(DeleteView):
"""Delete a user's self-review entry for a specific case attempt.
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")
def get_initial(self):
user_exam_id = self.kwargs["user_exam_id"]
case_id = self.kwargs["case_id"]
review_id = self.kwargs["pk"]
self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
self.case = get_object_or_404(Case, pk=case_id)
self.review = get_object_or_404(SelfReview, pk=review_id)
return {"user_exam": user_exam_id, "case": case_id, "review": review_id}
def get_context_data(self, **kwargs):
# This can also be accessed via view.**** in the template
context = super(SelfReviewDelete, self).get_context_data(**kwargs)
context["user_exam"] = self.user_exam
context["case"] = self.case
context["review"] = self.review
return context
def get_success_url(self) -> str:
return self.user_exam.exam.get_case_take_url(self.case)
class SelfReviewDetail(DetailView):
"""Read-only self-review view for a specific user exam and case.
This is the default viewing mode for self reviews. Add ``?embed=1`` to
render the compact sidebar-friendly template.
"""
model = SelfReview
def get_template_names(self):
if self.request.GET.get("embed") == "1":
return ["atlas/partials/_selfreview_detail_embed.html"]
return ["atlas/selfreview_detail.html"]
def get_object(self, queryset=None):
return get_object_or_404(
SelfReview,
pk=self.kwargs["pk"],
user_exam_id=self.kwargs["user_exam_id"],
case_id=self.kwargs["case_id"],
)
def get_context_data(self, **kwargs):
context = super(SelfReviewDetail, self).get_context_data(**kwargs)
context["review"] = self.object
context["user_exam"] = self.object.user_exam
context["case"] = self.object.case
context["embed"] = self.request.GET.get("embed") == "1"
return context
class SelfReviewUpdate(UpdateView):
"""Edit an existing self-review entry for a case attempt.
Uses ``SelfReviewForm`` and redirects back to the case take page after save.
"""
model = SelfReview
form_class = SelfReviewForm
def get_template_names(self):
if self.request.GET.get("embed") == "1":
return ["atlas/partials/_selfreview_form_embed.html"]
return ["atlas/selfreview_form.html"]
def get_initial(self):
user_exam_id = self.kwargs["user_exam_id"]
case_id = self.kwargs["case_id"]
review_id = self.kwargs["pk"]
self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
self.case = get_object_or_404(Case, pk=case_id)
self.review = get_object_or_404(SelfReview, pk=review_id)
return {"user_exam": user_exam_id, "case": case_id, "review": review_id}
def get_context_data(self, **kwargs):
# This can also be accessed via view.**** in the template
context = super(SelfReviewUpdate, self).get_context_data(**kwargs)
context["user_exam"] = self.user_exam
context["case"] = self.case
context["review"] = self.review
context["embed"] = self.request.GET.get("embed") == "1"
context["popup"] = self.request.GET.get("popup") == "1"
return context
def get_success_url(self) -> str:
if getattr(self, "object", None) is not None:
return self.object.user_exam.exam.get_case_take_url(self.object.case)
user_exam_id = self.kwargs["user_exam_id"]
case_id = self.kwargs["case_id"]
user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
case = get_object_or_404(Case, pk=case_id)
return user_exam.exam.get_case_take_url(case)
def form_valid(self, form):
self.object = form.save()
if self.request.GET.get("popup") == "1":
return render(
self.request,
"atlas/partials/_selfreview_popup_success.html",
{"target_url": self.get_success_url()},
)
return HttpResponseRedirect(self.get_success_url())
# def get_object(self):
# return self.review
# return
# def get_initial(self):
# user_exam_id = self.kwargs["user_exam_id"]
# case_id = self.kwargs["case_id"]
# self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
# self.case = get_object_or_404(Case, pk=case_id)
# return {"user_exam": user_exam_id, "case": case_id}
class AddSelfReview(CreateView):
"""Create a new self-review entry linked to a user exam and case.
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
def get_template_names(self):
if self.request.GET.get("embed") == "1":
return ["atlas/partials/_selfreview_form_embed.html"]
return ["atlas/selfreview_form.html"]
# fields = ("author", "note_type", "note")
def get_form_kwargs(self):
kwargs = super(AddSelfReview, self).get_form_kwargs()
# update the kwargs for the form init method with yours
kwargs.update(self.kwargs) # self.kwargs contains all url conf params
# kwargs.update({"user": self.request.user})
return kwargs
def get_initial(self):
user_exam_id = self.kwargs["user_exam_id"]
case_id = self.kwargs["case_id"]
self.user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
self.case = get_object_or_404(Case, pk=case_id)
return {"user_exam": user_exam_id, "case": case_id}
def get_context_data(self, **kwargs):
# This can also be accessed via view.**** in the template
context = super(AddSelfReview, self).get_context_data(**kwargs)
context["user_exam"] = self.user_exam
context["case"] = self.case
context["embed"] = self.request.GET.get("embed") == "1"
context["popup"] = self.request.GET.get("popup") == "1"
return context
def get_success_url(self) -> str:
if getattr(self, "object", None) is not None:
return self.object.user_exam.exam.get_case_take_url(self.object.case)
user_exam_id = self.kwargs["user_exam_id"]
case_id = self.kwargs["case_id"]
user_exam = get_object_or_404(CidUserExam, pk=user_exam_id)
case = get_object_or_404(Case, pk=case_id)
return user_exam.exam.get_case_take_url(case)
def form_valid(self, form):
self.object = form.save()
if self.request.GET.get("popup") == "1":
return render(
self.request,
"atlas/partials/_selfreview_popup_success.html",
{"target_url": self.get_success_url()},
)
return HttpResponseRedirect(self.get_success_url())
# return reverse("atlas:collection_take_overview_user", kwargs={"pk": self.user_exam.collection.id })
# def form_valid(self, form):
# model = form.save(commit=False)
#
# model.save()
#
# response = super().form_valid(form)
#
# return response
@login_required
def uncategorised_dicoms(request):
dicoms = UncategorisedDicom.objects.filter(user=request.user)
data = []
for d in dicoms:
data.append((d.image.name, d.get_basic_dicom_tags()))
return JsonResponse(data, safe=False)
@login_required
def combine_series(request):
"""Combines a list of series into the first series in the list
NOTE: this will only migrate images
it will not migrate other models such as SeriesFinding or other details
"""
series_ids = request.POST.getlist("series-ids")
# series = get_object_or_404(Series, pk=series_id)
if len(series_ids) < 2:
return HttpResponse("Not enough series to combine")
base_series = None
series_to_add = []
for n, series_id in enumerate(series_ids):
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit one of the series")
if n == 0:
base_series = series
else:
series_to_add.append(series)
for series in series_to_add:
for image in series.images.all():
image.series = base_series
image.save()
series.delete()
return HttpResponse(f"Series {series_ids} combines")
# return HttpResponse("Fail")
class CaseCollectionAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
model = CaseCollection
form_class = CaseCollectionAuthorForm
template_name = "author_form.html"
def get_context_data(self, **kwargs):
context = super(CaseCollectionAuthorUpdate, self).get_context_data(**kwargs)
context["collection"] = context["object"]
return context
class ExamGroupsUpdate(ExamGroupsUpdateBase):
model = CaseCollection
form_class = ExamGroupsForm
def get_context_data(self, **kwargs):
context = super(ExamGroupsUpdate, self).get_context_data(**kwargs)
context["exam"] = context["object"]
return context
class CaseAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
model = Case
form_class = CaseAuthorForm
template_name = "author_form.html"
def get_context_data(self, **kwargs):
context = super(CaseAuthorUpdate, self).get_context_data(**kwargs)
context["collection"] = context["object"]
return context
class SeriesAuthorUpdate(RevisionMixin, AuthorRequiredMixin, UpdateView):
model = Series
form_class = SeriesAuthorForm
template_name = "author_form.html"
def get_context_data(self, **kwargs):
context = super(SeriesAuthorUpdate, self).get_context_data(**kwargs)
context["collection"] = context["object"]
return context
def question_schemas_preset(request):
schemas = [
json.dumps(
{
"type": "object",
"title": "Questions",
"required": ["laterality", "diagnosis"],
"properties": {
"test": {
"type": "string",
"title": "test string",
"description": "What side is the lesion on?",
},
"diagnosis": {
"enum": [
"Meningioma",
"Schwannoma",
"Neurofibroma",
"Ependymoma",
],
"type": "string",
"title": "Diagnosis",
"description": "What is the diagnosis?",
},
"laterality": {
"enum": ["left", "right"],
"type": "string",
"title": "Laterality",
"widget": "radio",
"description": "What side is the lesion on?",
},
"laterality (copy 2)": {
"enum": ["left", "right"],
"type": "string",
"title": "Lateralityaoue",
"widget": "radio",
"description": "What side is the lesion on?",
},
},
}
),
]
return render(
request,
"atlas/question_schemas_preset.html",
{
"schemas": schemas,
},
)
def collection_question_schemas(request, exam_id: int):
collection = get_object_or_404(CaseCollection, pk=exam_id)
questions = collection.cases.all().prefetch_related()
casedetails = (
CaseDetail.objects.filter(case__in=questions, collection=collection)
.prefetch_related("case")
.order_by("sort_order")
)
return render(
request,
"atlas/collection_question_schemas.html",
{
"collection": collection,
"casedetails": casedetails,
},
)
@user_is_collection_author_or_atlas_editor
def collection_apply_question_template(request, pk: int):
"""Apply the collection's case_question_template to all CaseDetail entries.
Scope: Collection author / atlas_editor. HTMX endpoint.
Accepts optional 'overwrite' query param to overwrite existing schemas.
"""
if not request.htmx:
raise Http404("Invalid request")
collection = get_object_or_404(CaseCollection, pk=pk)
if not collection.case_question_template:
return HttpResponse(
'<div class="alert alert-warning">No question template set on this collection.</div>'
)
overwrite = request.GET.get("overwrite", "false").lower() == "true"
updated_count = collection.apply_question_template_to_cases(overwrite_existing=overwrite)
return HttpResponse(
f'<div class="alert alert-success">Applied question template to {updated_count} case(s).</div>'
)
@user_is_collection_author_or_atlas_editor
def collection_sync_prerequisite_users(request, pk: int):
"""Copy valid cid_users and user_users from all prerequisite collections into this collection.
This endpoint is intended to be called via HTMX from the collection detail page.
"""
if not request.htmx:
raise Http404("Invalid request")
collection = get_object_or_404(CaseCollection, pk=pk)
# Collect the closure of collections: the target collection and all prerequisites (recursively)
to_visit = [collection]
visited = {}
while to_visit:
col = to_visit.pop()
if col.pk in visited:
continue
visited[col.pk] = col
for p in col.prerequisites.all():
if p.pk not in visited:
to_visit.append(p)
collections = list(visited.values())
# Compute the union of all cid users and user users across the set
union_cid_users = set()
union_user_users = set()
union_cid_groups = set()
union_user_groups = set()
for col in collections:
union_cid_users.update(list(col.valid_cid_users.all()))
union_user_users.update(list(col.valid_user_users.all()))
union_cid_groups.update(list(col.cid_user_groups.all()))
union_user_groups.update(list(col.user_user_groups.all()))
# Apply the union to each collection and count additions
total_added_cid = 0
total_added_user = 0
total_added_cid_groups = 0
total_added_user_groups = 0
per_collection_added = []
for col in collections:
added_cid = 0
added_user = 0
added_cid_groups = 0
added_user_groups = 0
for cid_user in union_cid_users:
if not col.valid_cid_users.filter(pk=cid_user.pk).exists():
col.valid_cid_users.add(cid_user)
added_cid += 1
for user in union_user_users:
if not col.valid_user_users.filter(pk=user.pk).exists():
col.valid_user_users.add(user)
added_user += 1
# Sync groups as well
for gid in union_cid_groups:
if not col.cid_user_groups.filter(pk=gid.pk).exists():
col.cid_user_groups.add(gid)
added_cid_groups += 1
for ug in union_user_groups:
if not col.user_user_groups.filter(pk=ug.pk).exists():
col.user_user_groups.add(ug)
added_user_groups += 1
added_user += added_user_groups
added_cid += added_cid_groups
if added_cid or added_user:
col.save()
total_added_cid += added_cid
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))
html = render_to_string(
"atlas/partials/sync_prereq_result.html",
{
"total_added_cid": total_added_cid,
"total_added_user": total_added_user,
"total_added_cid_groups": total_added_cid_groups,
"total_added_user_groups": total_added_user_groups,
"per_collection_added": per_collection_added,
},
request=request,
)
return HttpResponse(html)
def collection_reset_answers_user_list(request, exam_id: int):
if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id)
cid_users = collection.cid_users.all()
if cid_users:
return render(
request,
"atlas/collection_reset_answers_user_list.html",
{"collection": collection, "cid_users": cid_users},
)
else:
return HttpResponse("No user answers.")
else:
raise Http404("Invalid request")
@user_is_collection_author_or_atlas_editor
def collection_reset_answers_user(request, exam_id: int, user_id: int):
if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id)
# Select all case details (answers are linked to these)
casedetails = collection.casedetail_set.all().prefetch_related()
# Delete all answers
for case in casedetails:
user_answers = case.userreportanswer_set.filter(user_id=user_id)
user_answers.delete()
collection.cid_users.filter(user_user_id=user_id).delete()
collection.exam_user_status.filter(cid_user_exam__user_user_id=user_id).delete()
return HttpResponse(f"User [{user_id}] answers deleted")
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:
collection = get_object_or_404(CaseCollection, pk=exam_id)
# Select all case details (answers are linked to these)
casedetails = collection.casedetail_set.all().prefetch_related()
# Delete all answers
for case in casedetails:
cid_answers = case.cidreportanswer_set.filter(cid=cid)
cid_answers.delete()
collection.cid_users.filter(cid=cid).delete()
collection.exam_user_status.filter(cid_user_exam__cid__pk=cid).delete()
return HttpResponse(f"CID [{cid}] answers deleted")
else:
raise Http404("Invalid request")
def collection_reset_answers(request, exam_id: int):
if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id)
# Select all case details (answers are linked to these)
casedetails = collection.casedetail_set.all().prefetch_related()
# Delete all answers
for case in casedetails:
cid_answers = case.cidreportanswer_set.all()
cid_answers.delete()
user_answers = case.userreportanswer_set.all()
user_answers.delete()
# User statuses
collection.exam_user_status.all().delete()
# CidUserExams
collection.cid_users.all().delete()
return HttpResponse("Success")
else:
raise Http404("Invalid request")
def push_case_to_cimar(request, case_id):
push_case_to_cimar_task.enqueue(case_id=case_id)
return HttpResponse("Push started")
case = get_object_or_404(Case, pk=case_id)
api = CimarAPI()
api.login(username=CIMAR_USERNAME, password=CIMAR_PASSWORD)
# We use the same study_uid for all images in a case
study_uid = generate_uid()
for series in case.get_series():
for image in series.images.all():
data = api.upload_dicom(image.image.path, study_uid=study_uid)
cimar_uuid = api.get_study_by_study_uid(data["study_uid"])["uuid"]
case.cimar_uuid = cimar_uuid
case.save()
cimar_case, created = CimarCase.objects.get_or_create(uuid=cimar_uuid)
cimar_case.refresh_study()
return HttpResponse("Success")
@require_POST
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 <li>).
if request.htmx:
return HttpResponse("")
return HttpResponse(f"Case removed from collection: {collection.name}")
@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')
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()
)
if not linked_series_ids:
return HttpResponse("No valid series selected for removal.", status=400)
# Remove the association (for a through model)
SeriesDetail.objects.filter(case=case, series_id__in=linked_series_ids).delete()
return HttpResponse(f"Removed {len(linked_series_ids)} series from this case.")
@login_required
@require_POST
def move_selected_series_to_case(request, case_pk):
source_case = get_object_or_404(Case, pk=case_pk)
if not source_case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit this case.", status=403)
form = MoveSelectedSeriesForm(request.POST, user=request.user, source_case=source_case)
if not form.is_valid():
return HttpResponse("Please choose a destination case.", status=400)
target_case = form.cleaned_data["destination_case"]
if not target_case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit the destination case.", status=403)
ids = request.POST.getlist("series-ids")
if not ids:
return HttpResponse("No series selected to move.")
series_to_move = list(
Series.objects.filter(pk__in=ids, case=source_case).distinct()
)
if not series_to_move:
return HttpResponse("No valid series selected to move.", status=400)
for series in series_to_move:
if not series.check_user_can_edit(request.user):
return HttpResponse(
"You do not have permission to edit one or more selected series.",
status=403,
)
next_sort_order = (
SeriesDetail.objects.filter(case=target_case).aggregate(max_sort=Max("sort_order"))["max_sort"] or -1
) + 1
added_to_target = 0
for series in series_to_move:
link_exists = SeriesDetail.objects.filter(case=target_case, series=series).exists()
if not link_exists:
SeriesDetail.objects.create(
case=target_case,
series=series,
sort_order=next_sort_order,
)
next_sort_order += 1
added_to_target += 1
SeriesDetail.objects.filter(case=source_case, series=series).delete()
return HttpResponse(
f"Moved {len(series_to_move)} series to case {target_case.pk}. Added {added_to_target} new links in destination case."
)
@require_POST
def reorder_series(request, case_pk):
case = get_object_or_404(Case, pk=case_pk)
if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit this case.", status=403)
# Get new order from POST
order = []
for key in sorted([k for k in request.POST if k.startswith("series_order_")]):
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)
return redirect(request.META.get("HTTP_REFERER", "/"))
def linked_cases_overview(request, case_id):
"""
Display and manage a linked-case chain.
Scope:
- Read-only overview of previous/current/next linked cases.
- Study-date editing for relative timeline management.
- On-demand bulk case-series editor for updating full case fields across the chain.
"""
case = get_object_or_404(Case, pk=case_id)
previous_cases = []
try:
current_case = case.previous_case
while current_case:
previous_cases.insert(0, current_case)
current_case = current_case.previous_case
except ObjectDoesNotExist:
pass
next_cases = []
try:
current_case = case.next_case
while current_case:
next_cases.append(current_case)
current_case = current_case.next_case
except ObjectDoesNotExist:
pass
chain_cases = previous_cases + [case] + next_cases
chain_case_ids = [linked_case.pk for linked_case in chain_cases]
editable_chain_case_ids = [
linked_case.pk
for linked_case in chain_cases
if linked_case.check_user_can_edit(request.user)
]
direct_previous = previous_cases[-1] if previous_cases else None
direct_next = next_cases[0] if next_cases else None
case_editor_prefix = "case_series_editor"
show_study_dates_editor = request.GET.get("show_dates") == "1"
show_case_editor = request.GET.get("show_editor") == "1"
show_reorder_editor = request.GET.get("show_reorder") == "1"
case_editor_selected_field = ""
case_editor_selected_source = ""
case_editor_apply_fields = [
field_name
for field_name in CaseForm.Meta.fields
if field_name != "previous_case"
]
case_editor_apply_field_choices = [
(
field_name,
str(Case._meta.get_field(field_name).verbose_name).replace("_", " ").title(),
)
for field_name in case_editor_apply_fields
]
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",
)
)
formset = case_editor_formset_factory(
data=data,
queryset=queryset,
prefix=case_editor_prefix,
form_kwargs={"user": request.user},
)
forms_by_pk = {form.instance.pk: form for form in formset.forms}
ordered_rows = []
for linked_case in chain_cases:
form = forms_by_pk.get(linked_case.pk)
if form is None:
continue
ordered_rows.append(
{
"case": linked_case,
"form": form,
"is_current": linked_case.pk == case.pk,
}
)
return formset, ordered_rows
case_editor_formset = None
case_editor_rows = []
if request.method == "POST":
form_action = request.POST.get("form_action", "study_dates")
if form_action == "case_series_editor":
if not editable_chain_case_ids:
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)
if case_editor_formset.is_valid():
case_editor_formset.save()
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.")
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.")
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)
if case_field.many_to_many:
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)
applied_count += 1
else:
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])
applied_count += 1
if applied_count:
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")
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.")
# Get new order from POST
order = []
for key in sorted([k for k in request.POST if k.startswith("case_order_")]):
try:
order.append(int(request.POST[key]))
except ValueError:
pass
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]]
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")
else:
messages.error(request, "Invalid case ordering submitted.")
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.")
show_study_dates_editor = True
apply_to_all_from = request.POST.get("apply_to_all_from")
updated_cases = 0
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),
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}"))
for linked_case in chain_cases:
if not linked_case.check_user_can_edit(request.user):
continue
if linked_case.study_date != source_date:
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).")
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()
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).")
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()
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()),
None,
)
for linked_case in chain_cases:
study_date = linked_case.get_effective_study_date()
relative_days = None
relative_label = None
if anchor_date and study_date:
relative_days = (study_date - anchor_date).days
relative_label = _format_relative_date_offset(relative_days)
case_relative_labels[linked_case.pk] = relative_label
linked_case.relative_label = relative_label
linked_case_rows.append(
{
"case": linked_case,
"study_date": study_date,
"relative_days": relative_days,
"relative_label": relative_label,
"can_edit": linked_case.check_user_can_edit(request.user),
"is_current": linked_case.pk == case.pk,
}
)
return render(
request,
"atlas/linked_cases_overview.html",
{
"current_case": case,
"previous_cases": previous_cases,
"next_cases": next_cases,
"chain_cases": chain_cases,
"linked_case_rows": linked_case_rows,
"anchor_date": anchor_date,
"can_edit_chain": any(row["can_edit"] for row in linked_case_rows),
"chain_total": len(chain_cases),
"current_index": len(previous_cases) + 1,
"direct_previous": direct_previous,
"direct_next": direct_next,
"show_study_dates_editor": show_study_dates_editor,
"show_case_editor": show_case_editor,
"show_reorder_editor": show_reorder_editor,
"case_editor_formset": case_editor_formset,
"case_editor_rows": case_editor_rows,
"case_editor_apply_field_choices": case_editor_apply_field_choices,
"case_editor_selected_field": case_editor_selected_field,
"case_editor_selected_source": case_editor_selected_source,
"case_editor_prefix": case_editor_prefix,
"editable_case_count": len(editable_chain_case_ids),
"locked_case_count": len(chain_case_ids) - len(editable_chain_case_ids),
"case_relative_labels": case_relative_labels,
},
)
@login_required
def add_series_to_case(request, series_pk):
if request.method == "POST":
case_id = request.POST.get("case_id")
case = get_object_or_404(Case, pk=case_id)
if not case.check_user_can_edit(request.user):
raise PermissionDenied("You do not have permission to edit this case.")
series = get_object_or_404(Series, pk=series_pk)
# Associate the series with the case
series.case.add(case)
# 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 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":
case = get_object_or_404(Case, pk=case_pk)
if not case.check_user_can_edit(request.user):
raise PermissionDenied("You do not have permission to edit this case.")
series = get_object_or_404(Series, pk=series_pk)
# Remove the association
series.case.remove(case)
# 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 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})
@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})
@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})
def seriesfinding_migration_status(request):
"""
View to list all SeriesFinding objects and their migration status.
Shows whether annotation_json_3d and viewer_state_3d are populated.
"""
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
})
@login_required
@require_POST
def series_bulk_delete(request):
ids = request.POST.getlist("series_ids") or request.POST.getlist("series-ids")
if not ids:
response = HttpResponse("No series selected to delete.", status=400)
return response if request.htmx else redirect("atlas:series_view")
series_to_delete = list(Series.objects.filter(pk__in=ids).distinct())
if not series_to_delete:
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)]
if forbidden_series:
response = HttpResponse(
f"You do not have permission to delete {len(forbidden_series)} selected series.",
status=403,
)
return response if request.htmx else redirect("atlas:series_view")
deleted_count = len(series_to_delete)
for series in series_to_delete:
series.delete()
response = HttpResponse(f"Deleted {deleted_count} series.")
if request.htmx:
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):
"""
View to set up the display options for a specific case in a collection.
This can include setting which series to display, annotations, etc.
"""
# Accept either case_number (index into collection) or case_id (case PK).
casedetail = None
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)
else:
# Otherwise the route most likely supplied case_number; try resolving by index
collection = get_object_or_404(CaseCollection, pk=collection_id)
case_obj = collection.get_case_by_index(int(case_number))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Http404:
raise
except Exception:
raise Http404("Case not found in collection")
if request.method == "POST":
data = json.loads(request.body.decode("utf-8"))
if data.get("reset_viewerstate"):
casedetail.default_viewerstate = None
casedetail.save()
return HttpResponse("<span class='text-success'>Viewer state reset to default.</span>")
viewerstate = data.get("viewerstate")
if viewerstate is not None:
casedetail.default_viewerstate = viewerstate
casedetail.save()
return HttpResponse("<span class='text-success'>Viewer state saved.</span>")
else:
return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400)
collection = casedetail.collection
case_number, case_count = collection.get_index_of_case(
casedetail.case, case_count=True
)
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,
})