Compare commits
19
Commits
bdb7b653b6
...
d2f4c21883
| 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.db.models import Q
|
||||
from django.http import QueryDict
|
||||
|
||||
|
||||
def get_authors(request):
|
||||
@@ -85,6 +86,22 @@ class CaseCollectionFilter(django_filters.FilterSet):
|
||||
user=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():
|
||||
queryset = queryset.prefetch_related("author").filter(
|
||||
author__id=request.user.id
|
||||
|
||||
+35
-5
@@ -720,11 +720,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
series_qs = self.series.all()
|
||||
if exclude_series_ids is not None:
|
||||
series_qs = self.series.exclude(id__in=exclude_series_ids)
|
||||
images = [
|
||||
[image.image.url for image in series.images.all()]
|
||||
for series in series_qs.order_by('seriesdetail__sort_order').prefetch_related('images')
|
||||
]
|
||||
series_qs = series_qs.exclude(id__in=exclude_series_ids)
|
||||
|
||||
# Ensure series are ordered by the through model sort_order
|
||||
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:
|
||||
return json.dumps(images)
|
||||
@@ -1039,6 +1048,27 @@ class Series(SeriesBase):
|
||||
else:
|
||||
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):
|
||||
if self.case:
|
||||
case_id = ", ".join([str(case) for case in self.case.all()])
|
||||
|
||||
@@ -316,7 +316,6 @@
|
||||
|
||||
<!-- Series Findings -->
|
||||
<h5>Series Findings</h5>
|
||||
{% if case.ordered_series %}
|
||||
{% for series in case.ordered_series %}
|
||||
{% for finding in series.findings.all %}
|
||||
<div class="finding-box" id="finding-box-{{ finding.pk }}" data-series="{{ series.pk }}">
|
||||
@@ -356,14 +355,12 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% empty%}
|
||||
No series associated with case.
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- 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">
|
||||
@@ -425,11 +422,10 @@
|
||||
</details>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<span class="text-muted">No display sets for this case.</span>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<span class="text-muted">No display sets for this case.</span>
|
||||
{% endif %}
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
@@ -804,7 +800,7 @@
|
||||
dsModalBody.innerHTML = html;
|
||||
setTimeout(function () {
|
||||
try { window.mountDicomViewers(); } catch (e) { console.warn('mountDicomViewers not available', e); }
|
||||
}, 200);
|
||||
}, 500);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error loading display set details:", error);
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<div class="case-displayset-modal">
|
||||
<div class="mb-3">
|
||||
<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 %}
|
||||
<div class="text-muted">{{ ds.description }}</div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<div class="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick='importAnnotations_main_viewer({{ ds.annotations|safe }}); importViewerState_main_viewer({{ ds.viewerstate|safe }});'
|
||||
>Load into viewer</button>
|
||||
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 }});'
|
||||
>Reload display set</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
<h2 class="card-title mb-2">{{ collection.name }}</h2>
|
||||
|
||||
{% 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>
|
||||
@@ -158,4 +164,25 @@
|
||||
</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 %}
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<li><a class="dropdown-item" href="{% url 'atlas:collection_authors' collection.pk %}">Authors</a></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><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 %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'admin:atlas_casecollection_change' collection.id %}">Admin Edit</a></li>
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% else %}
|
||||
{{ casedetail.case.series.count }} series
|
||||
{{ casedetail.series_count }} series
|
||||
{% endif %}
|
||||
{% if casedetail.question_schema %}
|
||||
• <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">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'atlas:collection_case_displaysetup_legacy' casedetail.collection.pk casedetail.case.pk %}" title="Setup default display">
|
||||
<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' collection.pk casedetail.case.pk %}" title="Setup default display">
|
||||
<i class="bi bi-display"></i>
|
||||
</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>
|
||||
</a>
|
||||
|
||||
{% 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 %}
|
||||
<i class="bi bi-question-square text-success"></i>
|
||||
{% else %}
|
||||
@@ -18,27 +18,27 @@
|
||||
{% 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">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="status-indicator">
|
||||
{% if casedetail.default_viewerstate %}
|
||||
<button type="button" class="btn btn-sm btn-outline-success" aria-disabled="true" title="This case has a default viewerstate defined">
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Remove button (HTMX) - only shown when the surrounding template has `can_edit` true #}
|
||||
{% 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?">
|
||||
{% if can_edit %}
|
||||
<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">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
+30
-16
@@ -545,7 +545,12 @@ def case_displaysets_detail(request, pk):
|
||||
def case_displaysets_modal(request, pk):
|
||||
"""Return HTML fragment for a CaseDisplaySet to be shown inside a modal."""
|
||||
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):
|
||||
try:
|
||||
@@ -2735,7 +2740,7 @@ def case_order_dicom(request, pk):
|
||||
# TODO: don't try to order non stack series
|
||||
for series in case.series.all():
|
||||
try:
|
||||
if request.user in series.get_author_objects():
|
||||
if series.check_user_can_edit(request.user):
|
||||
series.order_by_dicom()
|
||||
else:
|
||||
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 = (
|
||||
CaseDetail.objects
|
||||
.filter(collection=collection)
|
||||
.select_related("case")
|
||||
.order_by("sort_order")
|
||||
.prefetch_related(
|
||||
# Prefetch series and their images directly on the case
|
||||
"case__series",
|
||||
"case__series__modality",
|
||||
"case__series__examination",
|
||||
"case__series__plane",
|
||||
"case__series__contrast",
|
||||
"case__series__images",
|
||||
_Prefetch("case__series", queryset=series_qs),
|
||||
_Prefetch("case__series__modality"),
|
||||
_Prefetch("case__series__examination"),
|
||||
_Prefetch("case__series__plane"),
|
||||
_Prefetch("case__series__contrast"),
|
||||
_Prefetch("case__caseresource_set"),
|
||||
_Prefetch("case__display_sets"),
|
||||
)
|
||||
.annotate(series_count=Count("case__seriesdetail"))
|
||||
)
|
||||
|
||||
|
||||
@@ -4322,18 +4339,15 @@ def use_dates_as_descriptions(request, pk):
|
||||
|
||||
return HttpResponse("Done")
|
||||
|
||||
def collection_case_dicom_json_review(request, exam_id, case_id):
|
||||
return collection_case_dicom_json(request, exam_id, case_id, review=True)
|
||||
def collection_case_dicom_json_review(request, exam_id, case_number):
|
||||
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)
|
||||
try:
|
||||
casedetail = CaseDetail.objects.get(case=case_id, collection=exam_id)
|
||||
except Exception:
|
||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||
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)
|
||||
except Exception:
|
||||
raise Http404("Case not found in collection")
|
||||
|
||||
@@ -70,6 +70,9 @@ class AuthorMixin():
|
||||
|
||||
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:
|
||||
default_per_page = 25
|
||||
|
||||
+1
-11
@@ -387,7 +387,7 @@ class SeriesImageBase(models.Model):
|
||||
super().save(*args, **kwargs) # Call the "real" save() method.
|
||||
|
||||
|
||||
class SeriesBase(models.Model):
|
||||
class SeriesBase(models.Model, AuthorMixin):
|
||||
info = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of stack, for admin organisation, will not be visible when taking",
|
||||
@@ -444,16 +444,6 @@ class SeriesBase(models.Model):
|
||||
|
||||
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):
|
||||
"""Returns a comma seperated text list of regions"""
|
||||
return str(self.examination)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<input class="form-control" id="sbas-question-search-input" name="q" type="search"
|
||||
placeholder="Search questions by stem, answers, category or tags"
|
||||
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-indicator="#sbas-question-search-indicator" />
|
||||
<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
|
||||
into the results container via HTMX.
|
||||
"""
|
||||
return render(request, "sbas/search.html", {})
|
||||
return render(request, "sbas/sbas_search.html", {})
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block content %}
|
||||
<div class="">
|
||||
<h2>404 error</h2>
|
||||
{% if request.user.is_staff%}
|
||||
{% if reason %}
|
||||
{{ reason }}
|
||||
{% endif %}
|
||||
@@ -10,5 +11,6 @@
|
||||
{% if resolved %}
|
||||
<h3>{{ resolved }}</h3>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user