improve cases series handling in collection
This commit is contained in:
+14
-1
@@ -953,7 +953,13 @@ CaseCollectionCaseFormSet = inlineformset_factory(
|
|||||||
CaseCollection,
|
CaseCollection,
|
||||||
Case.casecollection_set.through,
|
Case.casecollection_set.through,
|
||||||
form=CaseCollectionCaseForm,
|
form=CaseCollectionCaseForm,
|
||||||
exclude=["question_schema", "question_answers"],
|
exclude=[
|
||||||
|
"question_schema",
|
||||||
|
"question_answers",
|
||||||
|
"series_visibility_config",
|
||||||
|
"learner_comment",
|
||||||
|
"learner_review_comment",
|
||||||
|
],
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
extra=0,
|
extra=0,
|
||||||
max_num=50,
|
max_num=50,
|
||||||
@@ -1370,6 +1376,8 @@ class CaseDetailForm(ModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
"redact_history",
|
"redact_history",
|
||||||
"override_history",
|
"override_history",
|
||||||
|
"learner_comment",
|
||||||
|
"learner_review_comment",
|
||||||
"question_time_limit_override",
|
"question_time_limit_override",
|
||||||
"answer_entry_grace_period_override",
|
"answer_entry_grace_period_override",
|
||||||
]
|
]
|
||||||
@@ -1443,6 +1451,11 @@ class CaseDetailForm(ModelForm):
|
|||||||
HTML(history_block),
|
HTML(history_block),
|
||||||
Field("override_history"),
|
Field("override_history"),
|
||||||
Field("redact_history"),
|
Field("redact_history"),
|
||||||
|
Fieldset(
|
||||||
|
"Learner Comments",
|
||||||
|
Field("learner_comment"),
|
||||||
|
Field("learner_review_comment"),
|
||||||
|
),
|
||||||
Fieldset(
|
Fieldset(
|
||||||
"Case Timing Overrides",
|
"Case Timing Overrides",
|
||||||
Field("question_time_limit_override"),
|
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
@@ -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.",
|
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:
|
class Meta:
|
||||||
ordering = ("sort_order",)
|
ordering = ("sort_order",)
|
||||||
|
|
||||||
@@ -2270,23 +2293,72 @@ class CaseDetail(models.Model):
|
|||||||
return self.override_history
|
return self.override_history
|
||||||
return self.case.history or "No history provided"
|
return self.case.history or "No history provided"
|
||||||
|
|
||||||
def get_case_series_nested(self, include_priors=True):
|
def get_series_visibility_map(self) -> dict[str, str]:
|
||||||
case_series_images = self.case.get_series_images_nested(as_json=False)
|
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:
|
if include_priors:
|
||||||
logger.debug(f"Checking for prior cases for case {self.case}")
|
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}")
|
logger.debug(f"Found {self.case.prior_case.count()} prior cases for case {self.case}")
|
||||||
|
|
||||||
for prior in self.caseprior_set.all():
|
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}")
|
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))
|
case_series_images.extend(prior.prior_case.get_series_images_nested(as_json=False))
|
||||||
|
|
||||||
return json.dumps(case_series_images)
|
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):
|
def build_stacks_for(case_obj, prefix=None):
|
||||||
stacks = []
|
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 = list(series.get_images())
|
||||||
series_images_with_urls = [(img, f"{REMOTE_URL}{img.image.url}") for img in series_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]
|
images = [url for _, url in series_images_with_urls]
|
||||||
@@ -2316,6 +2388,10 @@ class CaseDetail(models.Model):
|
|||||||
# include priors as separate entries
|
# include priors as separate entries
|
||||||
if include_priors:
|
if include_priors:
|
||||||
for prior in self.caseprior_set.all():
|
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
|
prior_case = prior.prior_case
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,13 +14,54 @@
|
|||||||
<form method="POST" class="post-form">
|
<form method="POST" class="post-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% crispy form form.helper %}
|
{% 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>
|
<button type="submit" value="answer" name="submit" class="btn btn-primary">Save Changes</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<script>
|
<script>
|
||||||
</script>
|
</script>
|
||||||
<style></style>
|
<style></style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -101,6 +101,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
|
||||||
<div id="viewer-panel" class="mb-3">
|
<div id="viewer-panel" class="mb-3">
|
||||||
{% if collection.show_built_in_viewer %}
|
{% if collection.show_built_in_viewer %}
|
||||||
@@ -118,7 +130,7 @@
|
|||||||
<div id="viewer-container" class="viewer-resizable-container">
|
<div id="viewer-container" class="viewer-resizable-container">
|
||||||
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
|
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
|
||||||
data-auto-cache-stack="false"
|
data-auto-cache-stack="false"
|
||||||
data-named-stacks='{{casedetail.get_case_named_stacks}}'
|
data-named-stacks='{{ named_stacks_json }}'
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
|
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h2>Case: {{ case.title }}</h2>
|
<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 }}
|
{{ casedetail.question_schema }}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<details class="series-detail">
|
<details class="series-detail">
|
||||||
<summary class="fw-semibold">Series</summary>
|
<summary class="fw-semibold">Series</summary>
|
||||||
<div class="mt-2 d-flex flex-column gap-2">
|
<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="border rounded p-2 bg-body-tertiary">
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<div class="small">
|
<div class="small">
|
||||||
@@ -375,6 +375,11 @@
|
|||||||
$('#open-viewer-local').click(function() {
|
$('#open-viewer-local').click(function() {
|
||||||
$("#open-viewer-local").removeClass("flash-button");
|
$("#open-viewer-local").removeClass("flash-button");
|
||||||
viewerConnected = false;
|
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' %}");
|
const opened = openSecondaryWindow("{% url 'atlas:collection_viewer_local_progressive' %}");
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
showUserMessage('Unable to open viewer window. Please allow pop-ups for this site and try again.', 'danger');
|
showUserMessage('Unable to open viewer window. Please allow pop-ups for this site and try again.', 'danger');
|
||||||
|
|||||||
@@ -16,6 +16,14 @@
|
|||||||
</div>
|
</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">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>
|
<div class="mb-3 small">{{case.get_series_blocks|safe}}</div>
|
||||||
|
|
||||||
@@ -37,6 +45,12 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<input class="form-control form-control-sm" type="text" name="relation" value="{{relation}}" placeholder="Relation to case" />
|
<input class="form-control form-control-sm" type="text" name="relation" value="{{relation}}" placeholder="Relation to case" />
|
||||||
</div>
|
</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">
|
<div class="col-8">
|
||||||
<select class="form-select form-select-sm" name="prior_visibility" id="prior_visibility_{{case.id}}">
|
<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>
|
<option value="AL" {% if visibility == 'AL' %}selected{% endif %}>Always</option>
|
||||||
|
|||||||
@@ -175,10 +175,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var savedPayload = readPersistedViewerPayload();
|
// Intentionally do not auto-load persisted payload on startup.
|
||||||
if (savedPayload) {
|
// The local viva launcher should open to an empty state and wait
|
||||||
applyViewerPayload(savedPayload);
|
// for an explicit "open" message from the controller page.
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('storage', function (event) {
|
window.addEventListener('storage', function (event) {
|
||||||
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
|
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
|
||||||
|
|||||||
+166
-57
@@ -5798,8 +5798,15 @@ def collection_viva_local(request, pk):
|
|||||||
for casedetail in casedetails:
|
for casedetail in casedetails:
|
||||||
case = casedetail.case
|
case = casedetail.case
|
||||||
prefetched_series = getattr(case, "prefetched_series", [])
|
prefetched_series = getattr(case, "prefetched_series", [])
|
||||||
|
visible_prefetched_series = []
|
||||||
images_nested = []
|
images_nested = []
|
||||||
for series in prefetched_series:
|
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", [])
|
prefetched_images = getattr(series, "prefetched_images", [])
|
||||||
images_nested.append([
|
images_nested.append([
|
||||||
img.image.url
|
img.image.url
|
||||||
@@ -5816,6 +5823,7 @@ def collection_viva_local(request, pk):
|
|||||||
"report": case.report,
|
"report": case.report,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
case.visible_prefetched_series = visible_prefetched_series
|
||||||
case.series_images_nested_json = json.dumps(images_nested)
|
case.series_images_nested_json = json.dumps(images_nested)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
@@ -6949,6 +6957,23 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
except Exception:
|
except Exception:
|
||||||
raise Http404("Case not found in collection")
|
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:
|
if request.htmx:
|
||||||
# Ensure we can render the updated single-card partial and return it so HTMX
|
# Ensure we can render the updated single-card partial and return it so HTMX
|
||||||
# can swap the card on the client side.
|
# 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
|
# Already removed; continue and render the not-added card
|
||||||
pass
|
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
|
added = False
|
||||||
relation = ""
|
relation = ""
|
||||||
visibility = "AL"
|
visibility = "AL"
|
||||||
|
|
||||||
html = render_to_string(
|
return _render_prior_card_response(
|
||||||
"atlas/partials/_prior_card.html",
|
prior_case,
|
||||||
{
|
added=added,
|
||||||
"case": prior_case,
|
relation=relation,
|
||||||
"added": added,
|
visibility=visibility,
|
||||||
"relation": relation,
|
|
||||||
"visibility": visibility,
|
|
||||||
"casedetail": casedetail,
|
|
||||||
"collection": collection,
|
|
||||||
},
|
|
||||||
request=request,
|
|
||||||
)
|
)
|
||||||
return HttpResponse(html)
|
|
||||||
elif "prior_case_id" in request.POST:
|
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
|
# Return the card partial with an inline error so HTMX will replace
|
||||||
# the card and display the validation message rather than navigating
|
# the card and display the validation message rather than navigating
|
||||||
# to a new page or showing a plain text response.
|
# to a new page or showing a plain text response.
|
||||||
prior_case = Case.objects.get(pk=request.POST.get("prior_case_id"))
|
return _render_prior_card_response(
|
||||||
added = False
|
prior_case,
|
||||||
relation = request.POST.get("relation", "")
|
added=False,
|
||||||
visibility = request.POST.get("prior_visibility", "AL")
|
relation=relation,
|
||||||
html = render_to_string(
|
visibility=visibility,
|
||||||
"atlas/partials/_prior_card.html",
|
error="Enter relation text, or use study-date relation.",
|
||||||
{
|
|
||||||
"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 HttpResponse(html)
|
|
||||||
prior_case = Case.objects.get(pk=request.POST["prior_case_id"])
|
|
||||||
p, created = CasePrior.objects.get_or_create(
|
p, created = CasePrior.objects.get_or_create(
|
||||||
casedetail=casedetail, prior_case=prior_case
|
casedetail=casedetail, prior_case=prior_case
|
||||||
)
|
)
|
||||||
p.relation_text = request.POST.get("relation", "")
|
p.relation_text = relation
|
||||||
p.prior_visibility = request.POST.get("prior_visibility", "AL")
|
p.prior_visibility = visibility
|
||||||
p.save()
|
p.save()
|
||||||
|
|
||||||
added = True
|
added = True
|
||||||
relation = p.relation_text
|
relation = p.relation_text
|
||||||
visibility = p.prior_visibility
|
visibility = p.prior_visibility
|
||||||
|
|
||||||
html = render_to_string(
|
return _render_prior_card_response(
|
||||||
"atlas/partials/_prior_card.html",
|
prior_case,
|
||||||
{
|
added=added,
|
||||||
"case": prior_case,
|
relation=relation,
|
||||||
"added": added,
|
visibility=visibility,
|
||||||
"relation": relation,
|
|
||||||
"visibility": visibility,
|
|
||||||
"casedetail": casedetail,
|
|
||||||
"collection": collection,
|
|
||||||
},
|
|
||||||
request=request,
|
|
||||||
)
|
)
|
||||||
return HttpResponse(html)
|
|
||||||
else:
|
else:
|
||||||
return HttpResponse("False", status=400)
|
return HttpResponse("False", status=400)
|
||||||
|
|
||||||
@@ -7212,10 +7244,35 @@ def collection_case_details(request, exam_id, case_number):
|
|||||||
except Exception:
|
except Exception:
|
||||||
raise Http404("Case not found in collection")
|
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":
|
if request.method == "POST":
|
||||||
form = CaseDetailForm(request.POST, instance=casedetail)
|
form = CaseDetailForm(request.POST, instance=casedetail)
|
||||||
if form.is_valid():
|
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:
|
if request.htmx:
|
||||||
return HttpResponse("Saved")
|
return HttpResponse("Saved")
|
||||||
return redirect(request.path)
|
return redirect(request.path)
|
||||||
@@ -7225,6 +7282,17 @@ def collection_case_details(request, exam_id, case_number):
|
|||||||
else:
|
else:
|
||||||
form = CaseDetailForm(instance=casedetail)
|
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(
|
case_number, case_count = collection.get_index_of_case(
|
||||||
casedetail.case, case_count=True
|
casedetail.case, case_count=True
|
||||||
)
|
)
|
||||||
@@ -7238,6 +7306,8 @@ def collection_case_details(request, exam_id, case_number):
|
|||||||
{
|
{
|
||||||
"casedetail": casedetail,
|
"casedetail": casedetail,
|
||||||
"form": form,
|
"form": form,
|
||||||
|
"visibility_choices": visibility_choices,
|
||||||
|
"series_visibility_rows": series_visibility_rows,
|
||||||
"collection": collection,
|
"collection": collection,
|
||||||
"case": casedetail.case,
|
"case": casedetail.case,
|
||||||
"previous": previous,
|
"previous": previous,
|
||||||
@@ -7294,9 +7364,10 @@ def collection_mark_question(request, pk, case_number):
|
|||||||
previous = case_number > 0
|
previous = case_number > 0
|
||||||
next = case_number < (case_count - 1)
|
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 = []
|
answer_forms = []
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -7339,7 +7410,7 @@ def collection_mark_question(request, pk, case_number):
|
|||||||
form = CidReportAnswerMarkForm(instance=answer, prefix=answer.pk)
|
form = CidReportAnswerMarkForm(instance=answer, prefix=answer.pk)
|
||||||
answer_forms.append((answer, form))
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -8212,8 +8283,6 @@ def collection_case_view_take(
|
|||||||
if collection.collection_type in ("REP", "QUE"):
|
if collection.collection_type in ("REP", "QUE"):
|
||||||
form = ReportAnswerForm(instance=answer, casedetail=casedetail)
|
form = ReportAnswerForm(instance=answer, casedetail=casedetail)
|
||||||
|
|
||||||
series_list = case.series.all().prefetch_related("images", "examination", "plane")
|
|
||||||
|
|
||||||
previous = case_number > 0
|
previous = case_number > 0
|
||||||
next = case_number < (case_count - 1)
|
next = case_number < (case_count - 1)
|
||||||
|
|
||||||
@@ -8249,6 +8318,8 @@ def collection_case_view_take(
|
|||||||
|
|
||||||
resources = case.caseresource_set.filter(pre_review=True)
|
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()
|
prior_cases = casedetail.caseprior_set.all()
|
||||||
|
|
||||||
series_to_load = []
|
series_to_load = []
|
||||||
@@ -8293,6 +8364,9 @@ def collection_case_view_take(
|
|||||||
"case": case,
|
"case": case,
|
||||||
"casedetail": casedetail,
|
"casedetail": casedetail,
|
||||||
"series_list": series_list,
|
"series_list": series_list,
|
||||||
|
"named_stacks_json": casedetail.get_case_named_stacks(
|
||||||
|
review_mode=question_completed
|
||||||
|
),
|
||||||
"series_to_load": series_to_load,
|
"series_to_load": series_to_load,
|
||||||
"has_priors": has_priors,
|
"has_priors": has_priors,
|
||||||
"prior_count": prior_count,
|
"prior_count": prior_count,
|
||||||
@@ -8437,12 +8511,47 @@ def collection_case_dicom_json(request, exam_id, case_number, review=False):
|
|||||||
except Exception:
|
except Exception:
|
||||||
raise Http404("Case not found in collection")
|
raise Http404("Case not found in collection")
|
||||||
|
|
||||||
if review:
|
main_series = casedetail.get_visible_case_series(review_mode=review)
|
||||||
priors = casedetail.caseprior_set.exclude(prior_visibility="NO")
|
|
||||||
else:
|
|
||||||
priors = casedetail.caseprior_set.exclude(prior_visibility__in=["NO", "RE"])
|
|
||||||
|
|
||||||
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):
|
def case_dicom_json(request, pk):
|
||||||
|
|||||||
Reference in New Issue
Block a user