improve cases series handling in collection

This commit is contained in:
Ross
2026-06-01 12:57:35 +01:00
parent 92d4ece061
commit 213ee17328
10 changed files with 378 additions and 71 deletions
+14 -1
View File
@@ -953,7 +953,13 @@ CaseCollectionCaseFormSet = inlineformset_factory(
CaseCollection,
Case.casecollection_set.through,
form=CaseCollectionCaseForm,
exclude=["question_schema", "question_answers"],
exclude=[
"question_schema",
"question_answers",
"series_visibility_config",
"learner_comment",
"learner_review_comment",
],
can_delete=True,
extra=0,
max_num=50,
@@ -1370,6 +1376,8 @@ class CaseDetailForm(ModelForm):
fields = [
"redact_history",
"override_history",
"learner_comment",
"learner_review_comment",
"question_time_limit_override",
"answer_entry_grace_period_override",
]
@@ -1443,6 +1451,11 @@ class CaseDetailForm(ModelForm):
HTML(history_block),
Field("override_history"),
Field("redact_history"),
Fieldset(
"Learner Comments",
Field("learner_comment"),
Field("learner_review_comment"),
),
Fieldset(
"Case Timing Overrides",
Field("question_time_limit_override"),
@@ -0,0 +1,28 @@
# Generated by Django 6.0.1 on 2026-06-01 11:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0105_case_study_date'),
]
operations = [
migrations.AddField(
model_name='casedetail',
name='learner_comment',
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner while taking the case and on review.'),
),
migrations.AddField(
model_name='casedetail',
name='learner_review_comment',
field=models.TextField(blank=True, default='', help_text='Comment shown to the learner only on review.'),
),
migrations.AddField(
model_name='casedetail',
name='series_visibility_config',
field=models.JSONField(blank=True, default=dict, help_text='Per-series visibility map for this case in this collection.'),
),
]
+80 -4
View File
@@ -2217,6 +2217,29 @@ class CaseDetail(models.Model):
help_text="Optional per-case override for additional answer-only time in seconds after case view lock.",
)
class SeriesVisibility(models.TextChoices):
ALWAYS = "AL", _("Show all the time")
REVIEW = "RE", _("Show on review")
HIDDEN = "NO", _("Do not show")
series_visibility_config = models.JSONField(
default=dict,
blank=True,
help_text="Per-series visibility map for this case in this collection.",
)
learner_comment = models.TextField(
blank=True,
default="",
help_text="Comment shown to the learner while taking the case and on review.",
)
learner_review_comment = models.TextField(
blank=True,
default="",
help_text="Comment shown to the learner only on review.",
)
class Meta:
ordering = ("sort_order",)
@@ -2270,23 +2293,72 @@ class CaseDetail(models.Model):
return self.override_history
return self.case.history or "No history provided"
def get_case_series_nested(self, include_priors=True):
case_series_images = self.case.get_series_images_nested(as_json=False)
def get_series_visibility_map(self) -> dict[str, str]:
raw_map = self.series_visibility_config or {}
if not isinstance(raw_map, dict):
return {}
valid_values = {
self.SeriesVisibility.ALWAYS,
self.SeriesVisibility.REVIEW,
self.SeriesVisibility.HIDDEN,
}
cleaned = {}
for key, value in raw_map.items():
key_str = str(key)
value_str = str(value)
if value_str in valid_values:
cleaned[key_str] = value_str
return cleaned
def get_series_visibility_for(self, series, review_mode: bool = False) -> str:
visibility = self.get_series_visibility_map().get(
str(series.pk), self.SeriesVisibility.ALWAYS
)
if visibility == self.SeriesVisibility.REVIEW and not review_mode:
return self.SeriesVisibility.HIDDEN
return visibility
def get_visible_case_series(self, review_mode: bool = False):
visible = []
for series in self.case.get_ordered_series():
visibility = self.get_series_visibility_for(series, review_mode=review_mode)
if visibility != self.SeriesVisibility.HIDDEN:
visible.append(series)
return visible
def get_case_series_nested(self, include_priors=True, review_mode: bool = False):
case_series_images = []
for series in self.get_visible_case_series(review_mode=review_mode):
case_series_images.append([
image.image.url
for image in series.images.filter(removed=False)
if image.image
])
if include_priors:
logger.debug(f"Checking for prior cases for case {self.case}")
logger.debug(f"Found {self.case.prior_case.count()} prior cases for case {self.case}")
for prior in self.caseprior_set.all():
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
continue
logger.debug(f"Adding prior case {prior.prior_case} to case {self.case}")
case_series_images.extend(prior.prior_case.get_series_images_nested(as_json=False))
return json.dumps(case_series_images)
def get_case_named_stacks(self, include_priors=True):
def get_case_named_stacks(self, include_priors=True, review_mode: bool = False):
def build_stacks_for(case_obj, prefix=None):
stacks = []
for series in case_obj.get_ordered_series():
if case_obj == self.case:
series_iterable = self.get_visible_case_series(review_mode=review_mode)
else:
series_iterable = case_obj.get_ordered_series()
for series in series_iterable:
series_images = list(series.get_images())
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_images]
images = [url for _, url in series_images_with_urls]
@@ -2316,6 +2388,10 @@ class CaseDetail(models.Model):
# include priors as separate entries
if include_priors:
for prior in self.caseprior_set.all():
if prior.prior_visibility == CasePrior.PriorVisibility.NONE:
continue
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
continue
prior_case = prior.prior_case
results.append(
{
@@ -14,13 +14,54 @@
<form method="POST" class="post-form">
{% csrf_token %}
{% crispy form form.helper %}
<div class="card mb-3">
<div class="card-header">
<strong>Series Visibility</strong>
</div>
<div class="card-body">
<p class="small text-muted mb-3">Choose how each series is shown for this case in this collection.</p>
{% if series_visibility_rows %}
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Series</th>
<th>Visibility</th>
</tr>
</thead>
<tbody>
{% for row in series_visibility_rows %}
<tr>
<td>
<div class="fw-semibold">Series {{ forloop.counter }}</div>
<div class="small text-muted">{{ row.series }}</div>
</td>
<td>
<select class="form-select form-select-sm" name="series_visibility_{{ row.series.pk }}">
{% for value, label in visibility_choices %}
<option value="{{ value }}" {% if row.value == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted">No series linked to this case.</div>
{% endif %}
</div>
</div>
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
</form>
{% endblock %}
{% block js %}
<script>
</script>
<style></style>
<script>
</script>
<style></style>
{% endblock %}
@@ -101,6 +101,18 @@
</div>
{% endif %}
{% if casedetail.learner_comment %}
<div class="alert alert-info small">
<strong>Collection note:</strong> {{ casedetail.learner_comment|linebreaks }}
</div>
{% endif %}
{% if question_completed and casedetail.learner_review_comment %}
<div class="alert alert-secondary small">
<strong>Review note:</strong> {{ casedetail.learner_review_comment|linebreaks }}
</div>
{% endif %}
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
<div id="viewer-panel" class="mb-3">
{% if collection.show_built_in_viewer %}
@@ -118,7 +130,7 @@
<div id="viewer-container" class="viewer-resizable-container">
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
data-named-stacks='{{ named_stacks_json }}'
></div>
</div>
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
@@ -4,6 +4,16 @@
{% block content %}
<h2>Case: {{ case.title }}</h2>
{% if casedetail.learner_comment %}
<div class="alert alert-info small">
<strong>Collection note:</strong> {{ casedetail.learner_comment|linebreaks }}
</div>
{% endif %}
{% if casedetail.learner_review_comment %}
<div class="alert alert-secondary small">
<strong>Review note:</strong> {{ casedetail.learner_review_comment|linebreaks }}
</div>
{% endif %}
{{ casedetail.question_schema }}
@@ -56,7 +56,7 @@
<details class="series-detail">
<summary class="fw-semibold">Series</summary>
<div class="mt-2 d-flex flex-column gap-2">
{% for series in case.prefetched_series %}
{% for series in case.visible_prefetched_series %}
<div class="border rounded p-2 bg-body-tertiary">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="small">
@@ -375,6 +375,11 @@
$('#open-viewer-local').click(function() {
$("#open-viewer-local").removeClass("flash-button");
viewerConnected = false;
try {
localStorage.removeItem('rad_local_viewer_popout_state');
} catch (e) {
console.warn('Unable to clear previous viewer state', e);
}
const opened = openSecondaryWindow("{% url 'atlas:collection_viewer_local_progressive' %}");
if (!opened) {
showUserMessage('Unable to open viewer window. Please allow pop-ups for this site and try again.', 'danger');
@@ -16,6 +16,14 @@
</div>
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
<p class="card-text small text-muted mb-2">
Study date:
{% if case.study_date %}
<span class="fw-medium">{{ case.study_date }}</span>
{% else %}
<span class="fw-medium">Unknown</span>
{% endif %}
</p>
<div class="mb-3 small">{{case.get_series_blocks|safe}}</div>
@@ -37,6 +45,12 @@
<div class="col-12">
<input class="form-control form-control-sm" type="text" name="relation" value="{{relation}}" placeholder="Relation to case" />
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="use_study_date_relation" value="1" id="prior_use_date_{{case.id}}" />
<label class="form-check-label small" for="prior_use_date_{{case.id}}">Use study dates to generate relation text</label>
</div>
</div>
<div class="col-8">
<select class="form-select form-select-sm" name="prior_visibility" id="prior_visibility_{{case.id}}">
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
@@ -175,10 +175,9 @@
}
}
var savedPayload = readPersistedViewerPayload();
if (savedPayload) {
applyViewerPayload(savedPayload);
}
// Intentionally do not auto-load persisted payload on startup.
// The local viva launcher should open to an empty state and wait
// for an explicit "open" message from the controller page.
window.addEventListener('storage', function (event) {
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
+166 -57
View File
@@ -5798,8 +5798,15 @@ def collection_viva_local(request, pk):
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
@@ -5816,6 +5823,7 @@ def collection_viva_local(request, pk):
"report": case.report,
}
)
case.visible_prefetched_series = visible_prefetched_series
case.series_images_nested_json = json.dumps(images_nested)
return render(
@@ -6949,6 +6957,23 @@ def collection_case_priors(request, exam_id, case_number):
except Exception:
raise Http404("Case not found in collection")
def _render_prior_card_response(prior_case, added, relation, visibility, error=None):
html = render_to_string(
"atlas/partials/_prior_card.html",
{
"case": prior_case,
"added": added,
"relation": relation,
"visibility": visibility,
"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.
@@ -6962,72 +6987,79 @@ def collection_case_priors(request, exam_id, case_number):
# Already removed; continue and render the not-added card
pass
prior_case = Case.objects.get(pk=prior_pk)
prior_case = Case.objects.filter(pk=prior_pk).first()
if prior_case is None:
return HttpResponse("Prior case not found", status=404)
added = False
relation = ""
visibility = "AL"
html = render_to_string(
"atlas/partials/_prior_card.html",
{
"case": prior_case,
"added": added,
"relation": relation,
"visibility": visibility,
"casedetail": casedetail,
"collection": collection,
},
request=request,
return _render_prior_card_response(
prior_case,
added=added,
relation=relation,
visibility=visibility,
)
return HttpResponse(html)
elif "prior_case_id" in request.POST:
if not request.POST.get("relation"):
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)
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,
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.
prior_case = Case.objects.get(pk=request.POST.get("prior_case_id"))
added = False
relation = request.POST.get("relation", "")
visibility = request.POST.get("prior_visibility", "AL")
html = render_to_string(
"atlas/partials/_prior_card.html",
{
"case": prior_case,
"added": added,
"relation": relation,
"visibility": visibility,
"casedetail": casedetail,
"collection": collection,
"error": "You need to enter text to describe the relationship between the cases",
},
request=request,
return _render_prior_card_response(
prior_case,
added=False,
relation=relation,
visibility=visibility,
error="Enter relation text, or use study-date relation.",
)
return HttpResponse(html)
prior_case = Case.objects.get(pk=request.POST["prior_case_id"])
p, created = CasePrior.objects.get_or_create(
casedetail=casedetail, prior_case=prior_case
)
p.relation_text = request.POST.get("relation", "")
p.prior_visibility = request.POST.get("prior_visibility", "AL")
p.relation_text = relation
p.prior_visibility = visibility
p.save()
added = True
relation = p.relation_text
visibility = p.prior_visibility
html = render_to_string(
"atlas/partials/_prior_card.html",
{
"case": prior_case,
"added": added,
"relation": relation,
"visibility": visibility,
"casedetail": casedetail,
"collection": collection,
},
request=request,
return _render_prior_card_response(
prior_case,
added=added,
relation=relation,
visibility=visibility,
)
return HttpResponse(html)
else:
return HttpResponse("False", status=400)
@@ -7212,10 +7244,35 @@ def collection_case_details(request, exam_id, case_number):
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():
form.save()
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)
@@ -7225,6 +7282,17 @@ def collection_case_details(request, exam_id, case_number):
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
)
@@ -7238,6 +7306,8 @@ def collection_case_details(request, exam_id, case_number):
{
"casedetail": casedetail,
"form": form,
"visibility_choices": visibility_choices,
"series_visibility_rows": series_visibility_rows,
"collection": collection,
"case": casedetail.case,
"previous": previous,
@@ -7294,9 +7364,10 @@ def collection_mark_question(request, pk, case_number):
previous = case_number > 0
next = case_number < (case_count - 1)
casedetail = CaseDetail.objects.filter(case=case, collection=collection)
casedetail_qs = CaseDetail.objects.filter(case=case, collection=collection)
casedetail = casedetail_qs.first()
answers = CidReportAnswer.objects.filter(question__in=casedetail)
answers = CidReportAnswer.objects.filter(question__in=casedetail_qs)
answer_forms = []
if request.method == "POST":
@@ -7339,7 +7410,7 @@ def collection_mark_question(request, pk, case_number):
form = CidReportAnswerMarkForm(instance=answer, prefix=answer.pk)
answer_forms.append((answer, form))
series_list = case.series.all().prefetch_related("images", "examination", "plane")
series_list = casedetail.get_visible_case_series(review_mode=True) if casedetail else []
return render(
request,
@@ -8212,8 +8283,6 @@ def collection_case_view_take(
if collection.collection_type in ("REP", "QUE"):
form = ReportAnswerForm(instance=answer, casedetail=casedetail)
series_list = case.series.all().prefetch_related("images", "examination", "plane")
previous = case_number > 0
next = case_number < (case_count - 1)
@@ -8249,6 +8318,8 @@ def collection_case_view_take(
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 = []
@@ -8293,6 +8364,9 @@ def collection_case_view_take(
"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,
@@ -8437,12 +8511,47 @@ def collection_case_dicom_json(request, exam_id, case_number, review=False):
except Exception:
raise Http404("Case not found in collection")
if review:
priors = casedetail.caseprior_set.exclude(prior_visibility="NO")
else:
priors = casedetail.caseprior_set.exclude(prior_visibility__in=["NO", "RE"])
main_series = casedetail.get_visible_case_series(review_mode=review)
return JsonResponse(casedetail.case.get_case_dicom_json(priors=priors))
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: {prior.relation_text}",
}
)
return JsonResponse(studies_json)
def case_dicom_json(request, pk):