lots of improvements
This commit is contained in:
+3
-1
@@ -16,7 +16,8 @@ from .models import (
|
|||||||
Presentation,
|
Presentation,
|
||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
SeriesDetail,
|
SeriesDetail,
|
||||||
Resource
|
Resource,
|
||||||
|
CaseDisplaySet,
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
@@ -46,6 +47,7 @@ admin.site.register(CaseDetail)
|
|||||||
admin.site.register(UncategorisedDicom)
|
admin.site.register(UncategorisedDicom)
|
||||||
admin.site.register(SeriesDetail)
|
admin.site.register(SeriesDetail)
|
||||||
admin.site.register(Resource)
|
admin.site.register(Resource)
|
||||||
|
admin.site.register(CaseDisplaySet)
|
||||||
|
|
||||||
|
|
||||||
class DifferentialInline(admin.TabularInline):
|
class DifferentialInline(admin.TabularInline):
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from atlas.models import (
|
|||||||
Subspecialty,
|
Subspecialty,
|
||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
|
CaseDisplaySet,
|
||||||
)
|
)
|
||||||
|
|
||||||
from anatomy.models import Modality
|
from anatomy.models import Modality
|
||||||
@@ -964,3 +965,31 @@ SeriesFormSet = inlineformset_factory(
|
|||||||
extra=1,
|
extra=1,
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class CaseDisplaySetForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = CaseDisplaySet
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"findings",
|
||||||
|
"structures",
|
||||||
|
"conditions",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Display Set Name"}),
|
||||||
|
"description": forms.Textarea(attrs={"class": "form-control", "rows": 3, "placeholder": "Description"}),
|
||||||
|
"findings": autocomplete.ModelSelect2Multiple(
|
||||||
|
url="atlas:finding-autocomplete"
|
||||||
|
),
|
||||||
|
"structures": autocomplete.ModelSelect2Multiple(
|
||||||
|
url="atlas:structure-autocomplete"
|
||||||
|
),
|
||||||
|
"conditions": autocomplete.ModelSelect2Multiple(
|
||||||
|
url="atlas:condition-autocomplete"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# Optionally, you can filter queryset for findings/structures/conditions here if needed
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-07-14 09:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0072_casedisplayset'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='casedisplayset',
|
||||||
|
name='conditions',
|
||||||
|
field=models.ManyToManyField(blank=True, to='atlas.condition'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='casedisplayset',
|
||||||
|
name='findings',
|
||||||
|
field=models.ManyToManyField(blank=True, to='atlas.finding'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='casedisplayset',
|
||||||
|
name='structures',
|
||||||
|
field=models.ManyToManyField(blank=True, to='atlas.structure'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='casedisplayset',
|
||||||
|
name='case',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='display_sets', to='atlas.case'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+10
-1
@@ -1119,7 +1119,12 @@ class SeriesDetail(models.Model):
|
|||||||
ordering = ("sort_order",)
|
ordering = ("sort_order",)
|
||||||
|
|
||||||
class CaseDisplaySet(models.Model, AuthorMixin):
|
class CaseDisplaySet(models.Model, AuthorMixin):
|
||||||
case = models.ForeignKey(Case, on_delete=models.CASCADE)
|
"""
|
||||||
|
This is analogous to a SeriesFinding but for a Case (it has
|
||||||
|
access to all the series stacks).
|
||||||
|
|
||||||
|
"""
|
||||||
|
case = models.ForeignKey(Case, on_delete=models.CASCADE, related_name="display_sets")
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -1143,6 +1148,10 @@ class CaseDisplaySet(models.Model, AuthorMixin):
|
|||||||
help_text="Annotations for the display set",
|
help_text="Annotations for the display set",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
findings = models.ManyToManyField(Finding, blank=True)
|
||||||
|
structures = models.ManyToManyField(Structure, blank=True)
|
||||||
|
conditions = models.ManyToManyField(Condition, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CaseDetail(models.Model):
|
class CaseDetail(models.Model):
|
||||||
|
|||||||
@@ -118,6 +118,16 @@
|
|||||||
ID: {{ case.id }}
|
ID: {{ case.id }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details id="dicom-viewer-details">
|
||||||
|
<summary>Viewer</summary>
|
||||||
|
|
||||||
|
<div id="main_viewer" class="dicom-viewer-root"
|
||||||
|
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||||
|
data-auto-cache-stack="false"
|
||||||
|
data-images='{{case.get_series_images_nested}}'
|
||||||
|
></div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
|
<p class="pre-whitespace"><b>Description:</b> {{ case.description }}</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
@@ -227,8 +237,10 @@
|
|||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Series Findings -->
|
||||||
|
<h5>Series Findings</h5>
|
||||||
{% if case.series.all %}
|
{% if case.series.all %}
|
||||||
{% for detail in case.get_ordered_series %}
|
{% for series in case.get_ordered_series %}
|
||||||
{% for finding in series.findings.all %}
|
{% for finding in series.findings.all %}
|
||||||
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}"
|
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}"
|
||||||
_="on mouseenter
|
_="on mouseenter
|
||||||
@@ -237,7 +249,9 @@
|
|||||||
until mouseleave"
|
until mouseleave"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}"><button class="btn btn-primary btn-sm">View</button></a>
|
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
|
||||||
|
<button class="btn btn-primary btn-sm">View</button>
|
||||||
|
</a>
|
||||||
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
|
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
|
||||||
View in Modal
|
View in Modal
|
||||||
</button>
|
</button>
|
||||||
@@ -274,6 +288,75 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
No series associated with case.
|
No series associated with case.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Case Display Sets -->
|
||||||
|
<h5 class="mt-3">Case Display Sets</h5>
|
||||||
|
{% if case.display_sets %}
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for ds in case.display_sets.all %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<b>Name: {{ ds.name }}</b>
|
||||||
|
{% if ds.description %}
|
||||||
|
<span class="text-muted">({{ ds.description }})</span>
|
||||||
|
{% endif %}
|
||||||
|
<span>
|
||||||
|
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>Findings:</strong>
|
||||||
|
{% if ds.findings.all %}
|
||||||
|
<ul>
|
||||||
|
{% for finding in ds.findings.all %}
|
||||||
|
<li>{{ finding }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Structures:</strong>
|
||||||
|
{% if ds.structures.all %}
|
||||||
|
<ul>
|
||||||
|
{% for structure in ds.structures.all %}
|
||||||
|
<li>{{ structure }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Conditions:</strong>
|
||||||
|
{% if ds.conditions.all %}
|
||||||
|
<ul>
|
||||||
|
{% for condition in ds.conditions.all %}
|
||||||
|
<li>{{ condition }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<details>
|
||||||
|
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||||
|
<div>
|
||||||
|
<strong>Viewer State:</strong>
|
||||||
|
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Annotations:</strong>
|
||||||
|
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">No display sets for this case.</span>
|
||||||
|
{% endif %}
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -439,6 +522,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
|
const dicomDetails = document.getElementById("dicom-viewer-details");
|
||||||
|
let dicomViewerLoaded = false;
|
||||||
|
dicomDetails.addEventListener("toggle", function() {
|
||||||
|
if (!dicomViewerLoaded && dicomDetails.open) {
|
||||||
|
window.mountDicomViewers();
|
||||||
|
dicomViewerLoaded = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Move up/down logic
|
// Move up/down logic
|
||||||
document.querySelectorAll("#series-reorder-list .move-up, #series-reorder-list .move-down").forEach(function(btn) {
|
document.querySelectorAll("#series-reorder-list .move-up, #series-reorder-list .move-down").forEach(function(btn) {
|
||||||
btn.addEventListener("click", function(e) {
|
btn.addEventListener("click", function(e) {
|
||||||
|
|||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
{% extends 'atlas/base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
|
||||||
|
{% include 'atlas/question_link_header.html' %}
|
||||||
|
|
||||||
|
<h2>Display Sets: {{ case.title }}</h2>
|
||||||
|
|
||||||
|
<details class="help-text">
|
||||||
|
<summary><i class="bi bi-info-circle"></i> Help</summary>
|
||||||
|
<p>This page shows the display sets associated with the case.</p>
|
||||||
|
<p>If you have access you can add and edit display sets.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="main_viewer" class="dicom-viewer-root"
|
||||||
|
style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
|
||||||
|
data-auto-cache-stack="false"
|
||||||
|
data-images='{{case.get_series_images_nested}}'
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<button hx-get="{% url 'atlas:case_displaysets_add' case.pk %}" hx-target="#displayset-add-form" hx-swap="innerHTML">Add Display Set</button>
|
||||||
|
<div id="displayset-add-form"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group mb-4" id="display-set-list">
|
||||||
|
{% for ds in case.display_sets.all %}
|
||||||
|
<li class="list-group-item" id="displayset-{{ ds.pk }}">
|
||||||
|
{% include 'atlas/partials/displayset_row.html' %}
|
||||||
|
</li>
|
||||||
|
{% empty %}
|
||||||
|
<li class="list-group-item text-muted">No display sets for this case.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
window.mountDicomViewers();
|
||||||
|
|
||||||
|
|
||||||
|
{% if selected_displayset %}
|
||||||
|
setTimeout(function() {
|
||||||
|
importAnnotations_main_viewer({{ selected_displayset.annotations|safe }});
|
||||||
|
importViewerState_main_viewer({{ selected_displayset.viewerstate|safe }});
|
||||||
|
}, 500);
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
document.addEventListener("htmx:beforeRequest", function(evt) {
|
||||||
|
console.log("htmx:beforeRequest", evt.detail);
|
||||||
|
// Only for the display set add form
|
||||||
|
var form = evt.target;
|
||||||
|
console.log("Form:", form);
|
||||||
|
console.log("Form matches:", form.matches("form[hx-post*='{% url 'atlas:case_displaysets_add' case.pk %}']"));
|
||||||
|
if (form && form.matches("form[hx-post*='{% url 'atlas:case_displaysets_add' case.pk %}']")) {
|
||||||
|
// Set hidden fields from JS functions
|
||||||
|
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : "{}";
|
||||||
|
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : "{}";
|
||||||
|
evt.detail.requestConfig.parameters["annotations"] = annotations;
|
||||||
|
evt.detail.requestConfig.parameters["viewerstate"] = viewerState;
|
||||||
|
console.log(evt)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -29,7 +29,7 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
|
|||||||
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
document.getElementById('viewerstate-save-response').innerHTML = "<span class='text-danger'>Viewer state could not be retrieved.</span>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch("{% url 'atlas:collection_case_display_setup' case_detail.collection.pk case_detail.case.pk %}", {
|
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -46,7 +46,7 @@ document.getElementById('save-viewerstate').addEventListener('click', function()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
document.getElementById('reset-viewerstate').addEventListener('click', function() {
|
||||||
fetch("{% url 'atlas:collection_case_display_setup' case_detail.collection.pk case_detail.case.pk %}", {
|
fetch("{% url 'atlas:collection_case_displaysetup' case_detail.collection.pk case_detail.case.pk %}", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<li data-question_pk={{casedetail.case.pk}}><a title="sort_order: {{casedetail.sort_order}}" href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
<li data-question_pk={{casedetail.case.pk}}><a title="sort_order: {{casedetail.sort_order}}" href="{% url 'atlas:collection_case_view' pk=collection.pk case_number=forloop.counter0 %}">Case {{forloop.counter}}</a>
|
||||||
: {{casedetail.case.title}}
|
: {{casedetail.case.title}}
|
||||||
|
|
||||||
(<a href="{% url 'atlas:collection_case_display_setup' casedetail.collection.pk casedetail.case.pk %}">setup default display</a>
|
(<a href="{% url 'atlas:collection_case_displaysetup' casedetail.collection.pk casedetail.case.pk %}">setup default display</a>
|
||||||
{% if casedetail.default_viewerstate %}
|
{% if casedetail.default_viewerstate %}
|
||||||
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
|
<i class="bi bi-check text-success" title="This case has a default viewerstate defined"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<form
|
||||||
|
hx-post="{% if form.instance.pk %}{% url 'atlas:case_displaysets_edit' form.instance.pk %}{% else %}{% url 'atlas:case_displaysets_add' case.pk %}{% endif %}"
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="closest li"
|
||||||
|
{% else %}
|
||||||
|
hx-target="#display-set-list"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-on::after-request= "this.remove()"
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
{{ form.media }}
|
||||||
|
<input type="hidden" name="viewerstate" id="viewerstate-input">
|
||||||
|
<input type="hidden" name="annotations" id="annotations-input">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<li class="list-group-item d-flex align-items-start" id="displayset-{{ ds.pk }}">
|
||||||
|
<div class="me-3">
|
||||||
|
{% if ds.viewerstate and ds.annotations %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
style="min-width: 100px;"
|
||||||
|
onclick='importAnnotations_main_viewer({{ ds.annotations|safe }}); importViewerState_main_viewer({{ ds.viewerstate|safe }});'
|
||||||
|
>Load</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<b>{{ ds.name }}</b>
|
||||||
|
{% if ds.description %}
|
||||||
|
<span class="text-muted">({{ ds.description }})</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<strong>Findings:</strong>
|
||||||
|
{% if ds.findings.all %}
|
||||||
|
<ul>
|
||||||
|
{% for finding in ds.findings.all %}
|
||||||
|
<li>{{ finding }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Structures:</strong>
|
||||||
|
{% if ds.structures.all %}
|
||||||
|
<ul>
|
||||||
|
{% for structure in ds.structures.all %}
|
||||||
|
<li>{{ structure }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Conditions:</strong>
|
||||||
|
{% if ds.conditions.all %}
|
||||||
|
<ul>
|
||||||
|
{% for condition in ds.conditions.all %}
|
||||||
|
<li>{{ condition }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<details>
|
||||||
|
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||||
|
<div>
|
||||||
|
<strong>Viewer State:</strong>
|
||||||
|
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Annotations:</strong>
|
||||||
|
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button
|
||||||
|
hx-get="{% url 'atlas:case_displaysets_edit' ds.pk %}"
|
||||||
|
hx-target="#displayset-edit-form-{{ ds.pk }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="btn btn-warning btn-sm"
|
||||||
|
>Edit</button>
|
||||||
|
<button
|
||||||
|
hx-delete="{% url 'atlas:case_displaysets_delete' ds.pk %}"
|
||||||
|
hx-target="closest li"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="btn btn-danger btn-sm"
|
||||||
|
onclick="return confirm('Are you sure you want to delete this display set?');"
|
||||||
|
>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="displayset-edit-form-{{ ds.pk }}"></div>
|
||||||
|
</li>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
<a href="{% url 'atlas:case_detail' pk=case.pk %}" title="View the Case">View</a>
|
||||||
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
<a href="{% url 'atlas:case_update' pk=case.pk %}" title="Edit the Case">Edit</a>
|
||||||
<a href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
|
<a href="{% url 'atlas:case_series_update' pk=case.pk %}" title="Edit the Cases Series">Series</a>
|
||||||
|
<a href="{% url 'atlas:case_displaysets' pk=case.pk %}" title="View and edit case display sets">Display Sets</a>
|
||||||
<a href="{% url 'atlas:case_clone' pk=case.pk %}"
|
<a href="{% url 'atlas:case_clone' pk=case.pk %}"
|
||||||
title="Clone the Case (duplicate everything but the images)">Clone</a>
|
title="Clone the Case (duplicate everything but the images)">Clone</a>
|
||||||
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
<a href="{% url 'atlas:case_delete' pk=case.pk %}" title="Delete the Case">Delete</a>
|
||||||
|
|||||||
+7
-2
@@ -221,8 +221,8 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"collection/<int:collection_id>/<int:case_id>/display_setup",
|
"collection/<int:collection_id>/<int:case_id>/display_setup",
|
||||||
views.collection_case_display_setup,
|
views.collection_case_displaysetup,
|
||||||
name="collection_case_display_setup",
|
name="collection_case_displaysetup",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
|
"collection/<int:pk>/<int:case_number>/take/<int:cid>/<str:passcode>",
|
||||||
@@ -250,6 +250,11 @@ urlpatterns = [
|
|||||||
name="collection_dicom_json",
|
name="collection_dicom_json",
|
||||||
),
|
),
|
||||||
path("case/<int:pk>/authors", views.CaseAuthorUpdate.as_view(), name="case_authors"),
|
path("case/<int:pk>/authors", views.CaseAuthorUpdate.as_view(), name="case_authors"),
|
||||||
|
path("case/<int:pk>/display_sets", views.case_displaysets, name="case_displaysets"),
|
||||||
|
path("case/<int:pk>/display_sets/add", views.case_displaysets_add, name="case_displaysets_add"),
|
||||||
|
path("<int:pk>/display_sets/edit", views.case_displaysets_edit, name="case_displaysets_edit"),
|
||||||
|
path("<int:pk>/display_sets/detail", views.case_displaysets_detail, name="case_displaysets_detail"),
|
||||||
|
path("<int:pk>/display_sets/delete", views.case_displaysets_delete, name="case_displaysets_delete"),
|
||||||
path(
|
path(
|
||||||
"case/<int:pk>/dicom_json",
|
"case/<int:pk>/dicom_json",
|
||||||
views.case_dicom_json,
|
views.case_dicom_json,
|
||||||
|
|||||||
+134
-18
@@ -9,6 +9,8 @@ from django.shortcuts import render, get_object_or_404, redirect
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_POST
|
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.contrib.auth.models import User
|
# from django.contrib.auth.models import User
|
||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||||
@@ -47,6 +49,7 @@ from .forms import (
|
|||||||
CaseCollectionCaseFormSet,
|
CaseCollectionCaseFormSet,
|
||||||
CaseCollectionForm,
|
CaseCollectionForm,
|
||||||
CaseDetailForm,
|
CaseDetailForm,
|
||||||
|
CaseDisplaySetForm,
|
||||||
CaseForm,
|
CaseForm,
|
||||||
CaseResourceFormSet,
|
CaseResourceFormSet,
|
||||||
CaseUpdateSeriesForm,
|
CaseUpdateSeriesForm,
|
||||||
@@ -77,7 +80,9 @@ from .models import (
|
|||||||
Case,
|
Case,
|
||||||
CaseCollection,
|
CaseCollection,
|
||||||
CaseDetail,
|
CaseDetail,
|
||||||
|
CaseDisplaySet,
|
||||||
CasePrior,
|
CasePrior,
|
||||||
|
CaseResource,
|
||||||
CidReportAnswer,
|
CidReportAnswer,
|
||||||
Condition,
|
Condition,
|
||||||
Differential,
|
Differential,
|
||||||
@@ -196,6 +201,102 @@ class AtlasEditorRequiredMixin(object):
|
|||||||
return obj
|
return obj
|
||||||
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404
|
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_has_case_view_access
|
||||||
|
def case_displaysets(request, pk):
|
||||||
|
case = get_object_or_404(Case, pk=pk)
|
||||||
|
|
||||||
|
can_edit = case.check_user_can_edit(request.user)
|
||||||
|
|
||||||
|
# Get displayset id from GET parameter
|
||||||
|
displayset_id = request.GET.get("displayset")
|
||||||
|
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,
|
||||||
|
"can_edit": can_edit,
|
||||||
|
"selected_displayset": selected_displayset,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_has_case_view_access
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def case_displaysets_add(request, pk):
|
||||||
|
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):
|
||||||
|
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 delete 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)
|
||||||
|
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def case_displaysets_detail(request, pk):
|
||||||
|
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
|
||||||
|
case = displayset.case
|
||||||
|
# Redirect to the case_displaysets page with ?displayset=<pk>
|
||||||
|
return redirect(f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}")
|
||||||
|
|
||||||
|
def case_displaysets_delete(request, pk):
|
||||||
|
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.")
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_has_case_view_access
|
@user_has_case_view_access
|
||||||
@@ -205,25 +306,40 @@ def case_detail(request, pk):
|
|||||||
Case.objects
|
Case.objects
|
||||||
.select_related() # Add any FK fields if needed
|
.select_related() # Add any FK fields if needed
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
"series__examination",
|
Prefetch(
|
||||||
"series__plane",
|
"seriesdetail_set",
|
||||||
"series__contrast",
|
queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related(
|
||||||
"series__images",
|
Prefetch(
|
||||||
"series__findings__findings",
|
"series",
|
||||||
"series__findings__conditions",
|
queryset=Series.objects.select_related("modality", "examination", "plane", "contrast")
|
||||||
"series__findings__structures",
|
.prefetch_related(
|
||||||
"subspecialty",
|
Prefetch("images", queryset=SeriesImage.objects.filter(removed=False).order_by("position")),
|
||||||
"condition",
|
Prefetch(
|
||||||
"presentation",
|
"findings",
|
||||||
"pathological_process",
|
queryset=SeriesFinding.objects.prefetch_related(
|
||||||
#"differential",
|
Prefetch("findings", queryset=Finding.objects.all()),
|
||||||
"caseresource_set",
|
Prefetch("conditions", queryset=Condition.objects.all()),
|
||||||
"author",
|
Prefetch("structures", queryset=Structure.objects.all()),
|
||||||
"casecollection_set",
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Prefetch("subspecialty", queryset=Subspecialty.objects.all()),
|
||||||
|
Prefetch("condition", queryset=Condition.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)
|
.get(pk=pk)
|
||||||
)
|
)
|
||||||
|
|
||||||
can_edit = case.check_user_can_edit(request.user)
|
can_edit = case.check_user_can_edit(request.user)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
@@ -3349,7 +3465,7 @@ def series_bulk_delete(request):
|
|||||||
|
|
||||||
@user_is_collection_author_or_atlas_editor
|
@user_is_collection_author_or_atlas_editor
|
||||||
@csrf_exempt # Only if you have CSRF issues; otherwise, keep CSRF protection
|
@csrf_exempt # Only if you have CSRF issues; otherwise, keep CSRF protection
|
||||||
def collection_case_display_setup(request, collection_id, case_id):
|
def collection_case_displaysetup(request, collection_id, case_id):
|
||||||
"""
|
"""
|
||||||
View to set up the display options for a specific case in a collection.
|
View to set up the display options for a specific case in a collection.
|
||||||
This can include setting which series to display, annotations, etc.
|
This can include setting which series to display, annotations, etc.
|
||||||
@@ -3370,6 +3486,6 @@ def collection_case_display_setup(request, collection_id, case_id):
|
|||||||
else:
|
else:
|
||||||
return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400)
|
return HttpResponse("<span class='text-danger'>No viewer state provided.</span>", status=400)
|
||||||
|
|
||||||
return render(request, "atlas/collection_case_display_setup.html", {
|
return render(request, "atlas/collection_case_displaysetup.html", {
|
||||||
"case_detail": case_detail
|
"case_detail": case_detail
|
||||||
})
|
})
|
||||||
+41
-41
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user