Compare commits

..
19 Commits
Author SHA1 Message Date
Ross d2f4c21883 Add "View as viva" option to collection actions dropdown 2026-02-09 13:03:17 +00:00
Ross 1f5931aab2 Refactor question search template: rename and move to sbas_search.html 2026-02-09 12:50:31 +00:00
Ross 08c3558861 Add dismissible performance warning for large collections with persistence 2026-02-09 12:45:46 +00:00
Ross add7a1cf38 Add dismissible performance warning for large collections in collection_detail template 2026-02-09 12:44:19 +00:00
Ross 65f08cce6c Add performance warning for large collections in collection_detail template 2026-02-09 12:42:03 +00:00
Ross 3c8238d737 Add user permission checks for series editing and enhance author display method 2026-02-09 12:27:20 +00:00
Ross 23f33ac7ae Optimize collection_viva by aggressively prefetching and annotating related objects to prevent N+1 queries 2026-02-09 12:15:36 +00:00
Ross ab130a08ed Optimize collection_viva by annotating and prefetching related objects to prevent N+1 queries 2026-02-09 12:13:00 +00:00
Ross 45e01a78c1 Refactor case management links and button structure for improved readability and consistency 2026-02-09 11:24:43 +00:00
Ross 25e5ef78ed Update case series count and management links to use collection.pk for consistency 2026-02-09 11:24:33 +00:00
Ross 13dd79b508 Rename parameter 'case_id' to 'case_number' in collection_case_dicom_json_review for consistency 2026-02-09 11:15:57 +00:00
Ross 89986ba2e9 Refactor collection_case_dicom_json to use case_number instead of case_id for improved clarity and error handling 2026-02-09 11:14:53 +00:00
Ross f9e69e1206 Rename parameter 'case_id' to 'case_number' in collection_case_dicom_json function for clarity 2026-02-09 11:13:44 +00:00
Ross e7de6c8970 Set default archive filter to False in CaseCollectionFilter 2026-02-09 11:06:39 +00:00
Ross ea139456a8 Fix URL for editing display set in modal 2026-02-09 10:56:37 +00:00
Ross 035da12e96 Add edit permission check for display set modal and update template 2026-02-09 10:49:28 +00:00
Ross 3b9942054d Increase timeout for loading display set details and remove redundant script from modal 2026-02-09 10:34:20 +00:00
Ross 5761117e68 Enhance case display with modal functionality for findings and display sets 2026-02-09 10:33:06 +00:00
Ross f316dc9bcb Filter out removed images in Case model's image retrieval 2026-02-09 09:46:16 +00:00
14 changed files with 306 additions and 217 deletions
+17
View File
@@ -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
View File
@@ -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)
@@ -1039,6 +1048,27 @@ class Series(SeriesBase):
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:
case_id = ", ".join([str(case) for case in self.case.all()]) case_id = ", ".join([str(case) for case in self.case.all()])
@@ -316,7 +316,6 @@
<!-- 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 }}">
@@ -356,14 +355,12 @@
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
{% endfor %} {% empty%}
{% else %}
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">
@@ -425,11 +422,10 @@
</details> </details>
</div> </div>
</li> </li>
{% empty %}
<span class="text-muted">No display sets for this case.</span>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %}
<span class="text-muted">No display sets for this case.</span>
{% 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 %}
&nbsp;&nbsp;<span class="text-success">Questions defined</span> &nbsp;&nbsp;<span class="text-success">Questions defined</span>
@@ -1,14 +1,14 @@
<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>
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_details_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Case details"> <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> <i class="bi bi-info-square"></i>
</a> </a>
{% if collection.collection_type == "QUE" %} {% 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"> <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 %} {% if casedetail.question_schema %}
<i class="bi bi-question-square text-success"></i> <i class="bi bi-question-square text-success"></i>
{% else %} {% else %}
@@ -18,7 +18,7 @@
{% endif %} {% endif %}
{% if casedetail.case.previous_case %} {% 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"> <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> <i class="bi bi-link-45deg"></i>
</a> </a>
{% endif %} {% endif %}
@@ -36,7 +36,7 @@
{# 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>
+30 -16
View File
@@ -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,18 +4339,15 @@ 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)
try:
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
except Exception:
collection = get_object_or_404(CaseCollection, pk=exam_id) collection = get_object_or_404(CaseCollection, pk=exam_id)
try: try:
case_obj = collection.get_case_by_index(int(case_id)) case_obj = collection.get_case_by_index(int(case_number))
casedetail = CaseDetail.objects.get(case=case_obj, collection=collection) casedetail = CaseDetail.objects.get(case=case_obj, collection=collection)
except Exception: except Exception:
raise Http404("Case not found in collection") raise Http404("Case not found in collection")
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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 %}