Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f4c21883 | ||
|
|
1f5931aab2 | ||
|
|
08c3558861 | ||
|
|
add7a1cf38 | ||
|
|
65f08cce6c | ||
|
|
3c8238d737 | ||
|
|
23f33ac7ae | ||
|
|
ab130a08ed | ||
|
|
45e01a78c1 | ||
|
|
25e5ef78ed | ||
|
|
13dd79b508 | ||
|
|
89986ba2e9 | ||
|
|
f9e69e1206 | ||
|
|
e7de6c8970 | ||
|
|
ea139456a8 | ||
|
|
035da12e96 | ||
|
|
3b9942054d | ||
|
|
5761117e68 | ||
|
|
f316dc9bcb |
@@ -21,6 +21,7 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.http import QueryDict
|
||||||
|
|
||||||
|
|
||||||
def get_authors(request):
|
def get_authors(request):
|
||||||
@@ -85,6 +86,22 @@ class CaseCollectionFilter(django_filters.FilterSet):
|
|||||||
user=None,
|
user=None,
|
||||||
request=None,
|
request=None,
|
||||||
):
|
):
|
||||||
|
# Default the archive filter to False when not provided so collections
|
||||||
|
# are not archived by default in listings.
|
||||||
|
if data is None:
|
||||||
|
data = QueryDict('', mutable=True)
|
||||||
|
data['archive'] = 'false'
|
||||||
|
else:
|
||||||
|
# QueryDict instances need to be copied to be mutable
|
||||||
|
try:
|
||||||
|
if 'archive' not in data:
|
||||||
|
data = data.copy()
|
||||||
|
data['archive'] = 'false'
|
||||||
|
except Exception:
|
||||||
|
# Fallback for non-QueryDict mappings
|
||||||
|
if isinstance(data, dict) and 'archive' not in data:
|
||||||
|
data = dict(data)
|
||||||
|
data['archive'] = 'false'
|
||||||
if not request.user.groups.filter(name="atlas_editor").exists():
|
if not request.user.groups.filter(name="atlas_editor").exists():
|
||||||
queryset = queryset.prefetch_related("author").filter(
|
queryset = queryset.prefetch_related("author").filter(
|
||||||
author__id=request.user.id
|
author__id=request.user.id
|
||||||
|
|||||||
+35
-5
@@ -720,11 +720,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
|
|
||||||
series_qs = self.series.all()
|
series_qs = self.series.all()
|
||||||
if exclude_series_ids is not None:
|
if exclude_series_ids is not None:
|
||||||
series_qs = self.series.exclude(id__in=exclude_series_ids)
|
series_qs = series_qs.exclude(id__in=exclude_series_ids)
|
||||||
images = [
|
|
||||||
[image.image.url for image in series.images.all()]
|
# Ensure series are ordered by the through model sort_order
|
||||||
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images')
|
series_list = list(series_qs.order_by('seriesdetail__sort_order').prefetch_related('images'))
|
||||||
]
|
|
||||||
|
images = []
|
||||||
|
for series in series_list:
|
||||||
|
# If images were prefetched, use the cached list and filter in Python
|
||||||
|
prefetched = getattr(series, '_prefetched_objects_cache', {}).get('images')
|
||||||
|
if prefetched is not None:
|
||||||
|
imgs = [img.image.url for img in prefetched if not getattr(img, 'removed', False)]
|
||||||
|
else:
|
||||||
|
imgs = [image.image.url for image in series.images.filter(removed=False)]
|
||||||
|
images.append(imgs)
|
||||||
|
|
||||||
if as_json:
|
if as_json:
|
||||||
return json.dumps(images)
|
return json.dumps(images)
|
||||||
@@ -1038,6 +1047,27 @@ class Series(SeriesBase):
|
|||||||
return self.description
|
return self.description
|
||||||
else:
|
else:
|
||||||
return f"{self.examination} ({self.plane})"
|
return f"{self.examination} ({self.plane})"
|
||||||
|
|
||||||
|
def user_can_edit(self, user, allow_if_can_edit_cases=True) -> bool:
|
||||||
|
"""Check if the user can edit the series.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user (User): The user to check.
|
||||||
|
Returns:
|
||||||
|
bool: True if the user can edit the series, False otherwise.
|
||||||
|
"""
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.author.filter(id=user.id).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if allow_if_can_edit_cases:
|
||||||
|
# Check if user can edit any of the cases this series is in
|
||||||
|
for case in self.case.all():
|
||||||
|
if case.check_user_can_edit(user):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def get_full_str(self):
|
def get_full_str(self):
|
||||||
if self.case:
|
if self.case:
|
||||||
|
|||||||
@@ -316,120 +316,116 @@
|
|||||||
|
|
||||||
<!-- Series Findings -->
|
<!-- Series Findings -->
|
||||||
<h5>Series Findings</h5>
|
<h5>Series Findings</h5>
|
||||||
{% if case.ordered_series %}
|
{% for series in case.ordered_series %}
|
||||||
{% for series in case.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 }}">
|
<span>
|
||||||
|
<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 }}">
|
||||||
|
View in Modal
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{% if finding.findings %}
|
||||||
<span>
|
<span>
|
||||||
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}">
|
Findings: {% for f in finding.findings.all %}
|
||||||
<button class="btn btn-primary btn-sm">View</button>
|
{{f.get_link}},
|
||||||
</a>
|
{% endfor %}
|
||||||
<button class="btn btn-secondary btn-sm view-finding-modal" data-finding-id="{{ finding.pk }}" data-series-id="{{ series.pk }}">
|
|
||||||
View in Modal
|
|
||||||
</button>
|
|
||||||
</span>
|
</span>
|
||||||
{% if finding.findings %}
|
{% endif %}
|
||||||
<span>
|
{% if finding.conditions %}
|
||||||
Findings: {% for f in finding.findings.all %}
|
<span>
|
||||||
{{f.get_link}},
|
Conditions: {% for c in finding.conditions.all %}
|
||||||
{% endfor %}
|
{{c.get_link}},
|
||||||
</span>
|
{% endfor %}
|
||||||
{% endif %}
|
</span>
|
||||||
{% if finding.conditions %}
|
{% endif %}
|
||||||
<span>
|
{% if finding.structures %}
|
||||||
Conditions: {% for c in finding.conditions.all %}
|
<span>
|
||||||
{{c.get_link}},
|
Structure: {% for s in finding.structures.all %}
|
||||||
{% endfor %}
|
{{s.get_link}},
|
||||||
</span>
|
{% endfor %}
|
||||||
{% endif %}
|
</span>
|
||||||
{% if finding.structures %}
|
{% endif %}
|
||||||
<span>
|
{% if finding.description %}
|
||||||
Structure: {% for s in finding.structures.all %}
|
<span>
|
||||||
{{s.get_link}},
|
Description: {{finding.description}}
|
||||||
{% endfor %}
|
</span>
|
||||||
</span>
|
{% endif %}
|
||||||
{% endif %}
|
</div>
|
||||||
{% if finding.description %}
|
|
||||||
<span>
|
|
||||||
Description: {{finding.description}}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% empty%}
|
||||||
No series associated with case.
|
No series associated with case.
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
|
|
||||||
<!-- Case Display Sets -->
|
<!-- Case Display Sets -->
|
||||||
<h5 class="mt-3">Case Display Sets</h5>
|
<h5 class="mt-3">Case Display Sets</h5>
|
||||||
{% if case.display_sets %}
|
<ul class="list-group">
|
||||||
<ul class="list-group">
|
{% for ds in case.display_sets.all %}
|
||||||
{% for ds in case.display_sets.all %}
|
<li class="list-group-item">
|
||||||
<li class="list-group-item">
|
<b>Name: {{ ds.name }}</b>
|
||||||
<b>Name: {{ ds.name }}</b>
|
{% if ds.description %}
|
||||||
{% if ds.description %}
|
<span class="text-muted">({{ ds.description }})</span>
|
||||||
<span class="text-muted">({{ ds.description }})</span>
|
{% endif %}
|
||||||
|
<span>
|
||||||
|
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm view-displayset-modal ms-2" data-ds-id="{{ ds.pk }}" data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">View in Modal</button>
|
||||||
|
</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 %}
|
{% endif %}
|
||||||
<span>
|
</div>
|
||||||
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
|
<div>
|
||||||
<button type="button" class="btn btn-secondary btn-sm view-displayset-modal ms-2" data-ds-id="{{ ds.pk }}" data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">View in Modal</button>
|
<strong>Structures:</strong>
|
||||||
</span>
|
{% if ds.structures.all %}
|
||||||
<div>
|
<ul>
|
||||||
<strong>Findings:</strong>
|
{% for structure in ds.structures.all %}
|
||||||
{% if ds.findings.all %}
|
<li>{{ structure }}</li>
|
||||||
<ul>
|
{% endfor %}
|
||||||
{% for finding in ds.findings.all %}
|
</ul>
|
||||||
<li>{{ finding }}</li>
|
{% else %}
|
||||||
{% endfor %}
|
<span class="text-muted">None</span>
|
||||||
</ul>
|
{% endif %}
|
||||||
{% else %}
|
</div>
|
||||||
<span class="text-muted">None</span>
|
<div>
|
||||||
{% endif %}
|
<strong>Conditions:</strong>
|
||||||
</div>
|
{% if ds.conditions.all %}
|
||||||
<div>
|
<ul>
|
||||||
<strong>Structures:</strong>
|
{% for condition in ds.conditions.all %}
|
||||||
{% if ds.structures.all %}
|
<li>{{ condition }}</li>
|
||||||
<ul>
|
{% endfor %}
|
||||||
{% for structure in ds.structures.all %}
|
</ul>
|
||||||
<li>{{ structure }}</li>
|
{% else %}
|
||||||
{% endfor %}
|
<span class="text-muted">None</span>
|
||||||
</ul>
|
{% endif %}
|
||||||
{% else %}
|
</div>
|
||||||
<span class="text-muted">None</span>
|
<div>
|
||||||
{% endif %}
|
<details>
|
||||||
</div>
|
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
|
||||||
<div>
|
<div>
|
||||||
<strong>Conditions:</strong>
|
<strong>Viewer State:</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>
|
<pre class="small">{{ ds.viewerstate|default:"{}" }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Annotations:</strong>
|
<strong>Annotations:</strong>
|
||||||
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
|
<pre class="small">{{ ds.annotations|default:"{}" }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% empty %}
|
||||||
</ul>
|
<span class="text-muted">No display sets for this case.</span>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<span class="text-muted">No display sets for this case.</span>
|
</ul>
|
||||||
{% endif %}
|
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -804,7 +800,7 @@
|
|||||||
dsModalBody.innerHTML = html;
|
dsModalBody.innerHTML = html;
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
try { window.mountDicomViewers(); } catch (e) { console.warn('mountDicomViewers not available', e); }
|
try { window.mountDicomViewers(); } catch (e) { console.warn('mountDicomViewers not available', e); }
|
||||||
}, 200);
|
}, 500);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error("Error loading display set details:", error);
|
console.error("Error loading display set details:", error);
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
<div class="case-displayset-modal">
|
<div class="case-displayset-modal">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h5>{{ ds.name }}</h5>
|
<h5>{{ ds.name }}</h5>
|
||||||
|
{% if can_edit %}
|
||||||
|
<a href="{% url 'atlas:case_displaysets' ds.case.pk %}?displayset={{ ds.pk }}&edit=true" class="btn btn-sm btn-outline-secondary ms-2">Edit display set</a>
|
||||||
|
{% endif %}
|
||||||
{% if ds.description %}
|
{% if ds.description %}
|
||||||
<div class="text-muted">{{ ds.description }}</div>
|
<div class="text-muted">{{ ds.description }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Viewer area for the modal - mounted by rad/static/dv3d bundle #}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="modal_viewer_{{ ds.pk }}" class="dicom-viewer-root" style="height:420px;" data-auto-cache-stack="false" data-annotationjson="{{ ds.annotations }}" data-viewerstate="{{ ds.viewerstate }}"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if ds.viewerstate and ds.annotations %}
|
{% if ds.viewerstate and ds.annotations %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
onclick='importAnnotations_main_viewer({{ ds.annotations|safe }}); importViewerState_main_viewer({{ ds.viewerstate|safe }});'
|
onclick='(window.importAnnotations_modal_viewer_{{ ds.pk }}||window.importAnnotations_modal_viewer||window.importAnnotations)({{ ds.annotations|safe }}); (window.importViewerState_modal_viewer_{{ ds.pk }}||window.importViewerState_modal_viewer||window.importViewerState)({{ ds.viewerstate|safe }});'
|
||||||
>Load into viewer</button>
|
>Reload display set</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||||
|
|
||||||
{% include 'exam_notes.html' %}
|
{% include 'exam_notes.html' %}
|
||||||
|
{% if casesdetails|length > 20 %}
|
||||||
|
<div id="collection-warning-{{ collection.pk }}" class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||||
|
<strong>Performance warning:</strong> This collection contains {{ casesdetails|length }} cases — in some situations this may adversely affect performance, all features will still work but page loading may be slower. Consider splitting into smaller collections if performance is an issue.
|
||||||
|
<button id="collection-warning-close-{{ collection.pk }}" type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<p class="mb-1">
|
<p class="mb-1">
|
||||||
</p>
|
</p>
|
||||||
@@ -158,4 +164,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
try {
|
||||||
|
var pk = '{{ collection.pk }}';
|
||||||
|
var key = 'collection_warning_dismissed_' + pk;
|
||||||
|
var alertEl = document.getElementById('collection-warning-' + pk);
|
||||||
|
if (!alertEl) return;
|
||||||
|
if (localStorage.getItem(key) === '1') {
|
||||||
|
alertEl.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var btn = document.getElementById('collection-warning-close-' + pk);
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
try { localStorage.setItem(key, '1'); } catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('collection warning persistence error', e); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
|
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
|
<li><a class="dropdown-item text-danger" href="{% url 'atlas:exam_deleted' collection.id %}">Delete</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'atlas:collection_viva' collection.pk %}">View as viva</a></li>
|
||||||
{% if request.user.is_superuser %}
|
{% if request.user.is_superuser %}
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
|
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 small text-muted">
|
<div class="mt-2 small text-muted">
|
||||||
{% if casedetail.case.series.count == 0 %}
|
{% if casedetail.series_count|default:0 == 0 %}
|
||||||
<span class="text-warning">This case has no series / stacks attached</span>
|
<span class="text-warning">This case has no series / stacks attached</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ casedetail.case.series.count }} series
|
{{ casedetail.series_count }} series
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if casedetail.question_schema %}
|
{% if casedetail.question_schema %}
|
||||||
• <span class="text-success">Questions defined</span>
|
• <span class="text-success">Questions defined</span>
|
||||||
@@ -24,46 +24,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="btn-group btn-group-sm ms-2" role="group" aria-label="case-field-links">
|
<div class="btn-group btn-group-sm ms-2" role="group" aria-label="case-field-links">
|
||||||
<!-- History button: opens modal populated from hidden content on page -->
|
<!-- History button: opens modal populated from hidden content on page -->
|
||||||
{% if casedetail.case.history %}
|
{% if casedetail.case.history %}
|
||||||
<a class="btn btn-outline-secondary" href="#"
|
<a class="btn btn-outline-secondary" href="#"
|
||||||
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-history').innerHTML;"
|
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-history').innerHTML;"
|
||||||
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View history">
|
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View history">
|
||||||
<i class="bi bi-clock-history text-primary"></i>
|
<i class="bi bi-clock-history text-primary"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No history">
|
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No history">
|
||||||
<i class="bi bi-clock-history text-muted"></i>
|
<i class="bi bi-clock-history text-muted"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Discussion button -->
|
<!-- Discussion button -->
|
||||||
{% if casedetail.case.discussion %}
|
{% if casedetail.case.discussion %}
|
||||||
<a class="btn btn-outline-secondary" href="#"
|
<a class="btn btn-outline-secondary" href="#"
|
||||||
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-discussion').innerHTML;"
|
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-discussion').innerHTML;"
|
||||||
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View discussion">
|
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View discussion">
|
||||||
<i class="bi bi-chat-left-text text-primary"></i>
|
<i class="bi bi-chat-left-text text-primary"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No discussion">
|
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No discussion">
|
||||||
<i class="bi bi-chat-left-text text-muted"></i>
|
<i class="bi bi-chat-left-text text-muted"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Report button -->
|
<!-- Report button -->
|
||||||
{% if casedetail.case.report %}
|
{% if casedetail.case.report %}
|
||||||
<a class="btn btn-outline-secondary" href="#"
|
<a class="btn btn-outline-secondary" href="#"
|
||||||
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-report').innerHTML;"
|
onclick="document.querySelector('#case-field-modal .modal-content').innerHTML = document.getElementById('case-field-{{ casedetail.case.pk }}-report').innerHTML;"
|
||||||
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View report">
|
data-bs-toggle="modal" data-bs-target="#case-field-modal" title="View report">
|
||||||
<i class="bi bi-file-earmark-text text-primary"></i>
|
<i class="bi bi-file-earmark-text text-primary"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No report">
|
<a class="btn btn-outline-secondary disabled" aria-disabled="true" href="javascript:void(0);" title="No report">
|
||||||
<i class="bi bi-file-earmark-text text-muted"></i>
|
<i class="bi bi-file-earmark-text text-muted"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
|
<div class="btn-group btn-group-sm me-2" role="group" aria-label="management-actions">
|
||||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
|
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' collection.pk casedetail.case.pk %}" title="Setup default display">
|
||||||
<i class="bi bi-display"></i>
|
<i class="bi bi-display"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' collection.pk casedetail.case.pk %}" title="Case details">
|
||||||
|
<i class="bi bi-info-square"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if collection.collection_type == "QUE" %}
|
||||||
|
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" collection.pk casedetail.case.pk %}' title="Manage questions">
|
||||||
|
{% if casedetail.question_schema %}
|
||||||
|
<i class="bi bi-question-square text-success"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-question-square text-danger"></i>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details">
|
{% if casedetail.case.previous_case %}
|
||||||
<i class="bi bi-info-square"></i>
|
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" collection.pk casedetail.case.pk %}' title="Manage priors">
|
||||||
|
<i class="bi bi-link-45deg"></i>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if collection.collection_type == "QUE" %}
|
|
||||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_questions_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage questions">
|
|
||||||
{% if casedetail.question_schema %}
|
|
||||||
<i class="bi bi-question-square text-success"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="bi bi-question-square text-danger"></i>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if casedetail.case.previous_case %}
|
|
||||||
<a class="btn btn-outline-secondary" href='{% url "atlas:collection_case_priors_legacy" casedetail.collection.pk casedetail.case.pk %}' title="Manage priors">
|
|
||||||
<i class="bi bi-link-45deg"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
|
||||||
|
{% if casedetail.default_viewerstate %}
|
||||||
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
|
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
|
||||||
{% if casedetail.default_viewerstate %}
|
<i class="bi bi-check"></i>
|
||||||
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
|
</button>
|
||||||
<i class="bi bi-check"></i>
|
{% endif %}
|
||||||
</button>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
|
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
|
||||||
{% if can_edit %}
|
{% if can_edit %}
|
||||||
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk casedetail.collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?">
|
<form class="d-inline ms-2 m-0" hx-post="{% url 'atlas:remove_case_from_collection' casedetail.case.pk collection.pk %}" hx-target="closest li" hx-swap="outerHTML" hx-confirm="Remove this case from the collection?">
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection">
|
<button type="submit" class="btn btn-outline-danger btn-sm case-remove" title="Remove this case from the collection">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
+33
-19
@@ -545,7 +545,12 @@ def case_displaysets_detail(request, pk):
|
|||||||
def case_displaysets_modal(request, pk):
|
def case_displaysets_modal(request, pk):
|
||||||
"""Return HTML fragment for a CaseDisplaySet to be shown inside a modal."""
|
"""Return HTML fragment for a CaseDisplaySet to be shown inside a modal."""
|
||||||
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
|
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
|
||||||
return render(request, 'atlas/case_displayset_modal.html', {'ds': displayset})
|
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 case_displaysets_delete(request, pk):
|
def case_displaysets_delete(request, pk):
|
||||||
try:
|
try:
|
||||||
@@ -2735,7 +2740,7 @@ def case_order_dicom(request, pk):
|
|||||||
# TODO: don't try to order non stack series
|
# TODO: don't try to order non stack series
|
||||||
for series in case.series.all():
|
for series in case.series.all():
|
||||||
try:
|
try:
|
||||||
if request.user in series.get_author_objects():
|
if series.check_user_can_edit(request.user):
|
||||||
series.order_by_dicom()
|
series.order_by_dicom()
|
||||||
else:
|
else:
|
||||||
fail.append(f"{series.pk}: invalid permission")
|
fail.append(f"{series.pk}: invalid permission")
|
||||||
@@ -3106,20 +3111,32 @@ def collection_viva(request, pk):
|
|||||||
# )
|
# )
|
||||||
#)
|
#)
|
||||||
|
|
||||||
|
# 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 = (
|
casedetails = (
|
||||||
CaseDetail.objects
|
CaseDetail.objects
|
||||||
.filter(collection=collection)
|
.filter(collection=collection)
|
||||||
.select_related("case")
|
.select_related("case")
|
||||||
.order_by("sort_order")
|
.order_by("sort_order")
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
# Prefetch series and their images directly on the case
|
_Prefetch("case__series", queryset=series_qs),
|
||||||
"case__series",
|
_Prefetch("case__series__modality"),
|
||||||
"case__series__modality",
|
_Prefetch("case__series__examination"),
|
||||||
"case__series__examination",
|
_Prefetch("case__series__plane"),
|
||||||
"case__series__plane",
|
_Prefetch("case__series__contrast"),
|
||||||
"case__series__contrast",
|
_Prefetch("case__caseresource_set"),
|
||||||
"case__series__images",
|
_Prefetch("case__display_sets"),
|
||||||
)
|
)
|
||||||
|
.annotate(series_count=Count("case__seriesdetail"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -4322,21 +4339,18 @@ def use_dates_as_descriptions(request, pk):
|
|||||||
|
|
||||||
return HttpResponse("Done")
|
return HttpResponse("Done")
|
||||||
|
|
||||||
def collection_case_dicom_json_review(request, exam_id, case_id):
|
def collection_case_dicom_json_review(request, exam_id, case_number):
|
||||||
return collection_case_dicom_json(request, exam_id, case_id, review=True)
|
return collection_case_dicom_json(request, exam_id, case_number, review=True)
|
||||||
|
|
||||||
|
|
||||||
def collection_case_dicom_json(request, exam_id, case_id, review=False):
|
def collection_case_dicom_json(request, exam_id, case_number, review=False):
|
||||||
# Support either case_number (index) or case_id (case PK)
|
# Support either case_number (index) or case_id (case PK)
|
||||||
|
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||||
try:
|
try:
|
||||||
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
|
case_obj = collection.get_case_by_index(int(case_number))
|
||||||
|
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
||||||
except Exception:
|
except Exception:
|
||||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
raise Http404("Case not found in collection")
|
||||||
try:
|
|
||||||
case_obj = collection.get_case_by_index(int(case_id))
|
|
||||||
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
|
|
||||||
except Exception:
|
|
||||||
raise Http404("Case not found in collection")
|
|
||||||
|
|
||||||
if review:
|
if review:
|
||||||
priors = casedetail.caseprior_set.exclude(prior_visibility="NO")
|
priors = casedetail.caseprior_set.exclude(prior_visibility="NO")
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class AuthorMixin():
|
|||||||
|
|
||||||
return self.author.filter(id=user.id).exists()
|
return self.author.filter(id=user.id).exists()
|
||||||
|
|
||||||
|
def get_author_display(self):
|
||||||
|
return ", ".join([i.username for i in self.get_author_objects()])
|
||||||
|
|
||||||
|
|
||||||
class UserConfigurablePaginationMixin:
|
class UserConfigurablePaginationMixin:
|
||||||
default_per_page = 25
|
default_per_page = 25
|
||||||
|
|||||||
+1
-11
@@ -387,7 +387,7 @@ class SeriesImageBase(models.Model):
|
|||||||
super().save(*args, **kwargs) # Call the "real" save() method.
|
super().save(*args, **kwargs) # Call the "real" save() method.
|
||||||
|
|
||||||
|
|
||||||
class SeriesBase(models.Model):
|
class SeriesBase(models.Model, AuthorMixin):
|
||||||
info = models.TextField(
|
info = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Description of stack, for admin organisation, will not be visible when taking",
|
help_text="Description of stack, for admin organisation, will not be visible when taking",
|
||||||
@@ -444,16 +444,6 @@ class SeriesBase(models.Model):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_author_objects(self):
|
|
||||||
"""Returns a list of authors"""
|
|
||||||
if self.author:
|
|
||||||
return self.author.all()
|
|
||||||
else:
|
|
||||||
return ["None"]
|
|
||||||
|
|
||||||
def get_author_display(self):
|
|
||||||
return ", ".join([i.username for i in self.get_author_objects()])
|
|
||||||
|
|
||||||
def get_examination(self):
|
def get_examination(self):
|
||||||
"""Returns a comma seperated text list of regions"""
|
"""Returns a comma seperated text list of regions"""
|
||||||
return str(self.examination)
|
return str(self.examination)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<input class="form-control" id="sbas-question-search-input" name="q" type="search"
|
<input class="form-control" id="sbas-question-search-input" name="q" type="search"
|
||||||
placeholder="Search questions by stem, answers, category or tags"
|
placeholder="Search questions by stem, answers, category or tags"
|
||||||
hx-get="{% url 'sbas:question_search_partial' %}"
|
hx-get="{% url 'sbas:question_search_partial' %}"
|
||||||
hx-trigger="input changed delay:400ms, keyup[key=='Enter']"
|
hx-trigger="input delay:400ms, keyup[key=='Enter']"
|
||||||
hx-target="#sbas-question-search-results"
|
hx-target="#sbas-question-search-results"
|
||||||
hx-indicator="#sbas-question-search-indicator" />
|
hx-indicator="#sbas-question-search-indicator" />
|
||||||
<span class="input-group-text" id="sbas-question-search-indicator" aria-hidden="true">
|
<span class="input-group-text" id="sbas-question-search-indicator" aria-hidden="true">
|
||||||
+1
-1
@@ -244,7 +244,7 @@ def question_search_page(request):
|
|||||||
The actual results are returned by `question_search_partial` and swapped
|
The actual results are returned by `question_search_partial` and swapped
|
||||||
into the results container via HTMX.
|
into the results container via HTMX.
|
||||||
"""
|
"""
|
||||||
return render(request, "sbas/search.html", {})
|
return render(request, "sbas/sbas_search.html", {})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="">
|
<div class="">
|
||||||
<h2>404 error</h2>
|
<h2>404 error</h2>
|
||||||
|
{% if request.user.is_staff%}
|
||||||
{% if reason %}
|
{% if reason %}
|
||||||
{{ reason }}
|
{{ reason }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -10,5 +11,6 @@
|
|||||||
{% if resolved %}
|
{% if resolved %}
|
||||||
<h3>{{ resolved }}</h3>
|
<h3>{{ resolved }}</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user