Add caching and group detection for series images in series viewer
This commit is contained in:
+66
-5
@@ -10,6 +10,8 @@ from rad.settings import REMOTE_URL
|
|||||||
from django.db.models.fields.files import ImageField
|
from django.db.models.fields.files import ImageField
|
||||||
from django.db.models.fields.related import ForeignKey
|
from django.db.models.fields.related import ForeignKey
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import pre_save, post_save, post_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -129,11 +131,34 @@ def _normalise_group_rule_value(value):
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _series_declared_groups_cache_key(series_id):
|
||||||
|
return f"atlas:series_declared_groups:{series_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_series_declared_groups_cache(series_id):
|
||||||
|
if not series_id:
|
||||||
|
return
|
||||||
|
cache.delete(_series_declared_groups_cache_key(series_id))
|
||||||
|
|
||||||
|
|
||||||
def _build_declared_series_groups(series, series_images_with_urls):
|
def _build_declared_series_groups(series, series_images_with_urls):
|
||||||
cache_key = f"atlas:series_declared_groups:{series.pk}:{int(series.modified_date.timestamp())}:{len(series_images_with_urls)}"
|
cache_key = _series_declared_groups_cache_key(series.pk)
|
||||||
|
signature = {
|
||||||
|
"series_modified": int(series.modified_date.timestamp()) if getattr(series, "modified_date", None) else 0,
|
||||||
|
"image_count": len(series_images_with_urls),
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"pk": getattr(image_obj, "pk", None),
|
||||||
|
"position": getattr(image_obj, "position", None),
|
||||||
|
"url": image_url,
|
||||||
|
}
|
||||||
|
for image_obj, image_url in series_images_with_urls
|
||||||
|
],
|
||||||
|
}
|
||||||
cached = cache.get(cache_key)
|
cached = cache.get(cache_key)
|
||||||
if cached is not None:
|
if isinstance(cached, dict):
|
||||||
return cached
|
if cached.get("signature") == signature:
|
||||||
|
return cached.get("groups", [])
|
||||||
|
|
||||||
rules = [
|
rules = [
|
||||||
"dwiBValue",
|
"dwiBValue",
|
||||||
@@ -232,7 +257,7 @@ def _build_declared_series_groups(series, series_images_with_urls):
|
|||||||
"groupValues": {},
|
"groupValues": {},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
cache.set(cache_key, result, timeout=60 * 60)
|
cache.set(cache_key, {"signature": signature, "groups": result}, timeout=60 * 60)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
grouped = {}
|
grouped = {}
|
||||||
@@ -288,7 +313,7 @@ def _build_declared_series_groups(series, series_images_with_urls):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
cache.set(cache_key, ordered_groups, timeout=60 * 60)
|
cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
|
||||||
return ordered_groups
|
return ordered_groups
|
||||||
|
|
||||||
|
|
||||||
@@ -1274,6 +1299,42 @@ class SeriesImage(SeriesImageBase):
|
|||||||
# }}
|
# }}
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_save, sender=SeriesImage)
|
||||||
|
def _capture_previous_series_for_series_image(sender, instance, **kwargs):
|
||||||
|
if not getattr(instance, "pk", None):
|
||||||
|
instance._previous_series_id_for_cache = None
|
||||||
|
return
|
||||||
|
previous_series_id = (
|
||||||
|
sender.objects.filter(pk=instance.pk).values_list("series_id", flat=True).first()
|
||||||
|
)
|
||||||
|
instance._previous_series_id_for_cache = previous_series_id
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_and_touch_series(series_id):
|
||||||
|
if not series_id:
|
||||||
|
return
|
||||||
|
invalidate_series_declared_groups_cache(series_id)
|
||||||
|
Series.objects.filter(pk=series_id).update(modified_date=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=SeriesImage)
|
||||||
|
def _invalidate_declared_groups_on_series_image_save(sender, instance, **kwargs):
|
||||||
|
affected_series_ids = set()
|
||||||
|
if instance.series_id:
|
||||||
|
affected_series_ids.add(instance.series_id)
|
||||||
|
previous_series_id = getattr(instance, "_previous_series_id_for_cache", None)
|
||||||
|
if previous_series_id:
|
||||||
|
affected_series_ids.add(previous_series_id)
|
||||||
|
|
||||||
|
for series_id in affected_series_ids:
|
||||||
|
_invalidate_and_touch_series(series_id)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_delete, sender=SeriesImage)
|
||||||
|
def _invalidate_declared_groups_on_series_image_delete(sender, instance, **kwargs):
|
||||||
|
_invalidate_and_touch_series(getattr(instance, "series_id", None))
|
||||||
|
|
||||||
|
|
||||||
class SeriesFinding(FindingBase):
|
class SeriesFinding(FindingBase):
|
||||||
series = models.ForeignKey(
|
series = models.ForeignKey(
|
||||||
"Series", related_name="findings", on_delete=models.SET_NULL, null=True
|
"Series", related_name="findings", on_delete=models.SET_NULL, null=True
|
||||||
|
|||||||
@@ -32,10 +32,10 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
|
||||||
<div id="root"
|
<div id="root"
|
||||||
class="dicom-viewer-root w-100 viewer-frame-standard series-viewer-resizable"
|
class="dicom-viewer-root w-100 viewer-frame-standard series-viewer-resizable"
|
||||||
style="height: clamp(640px, 78vh, 1200px); min-height: 640px; resize: vertical; overflow: auto;"
|
style="height: clamp(640px, 78vh, 1200px); min-height: 640px; resize: vertical; overflow: auto;"
|
||||||
data-images="{{ image_url_array_and_count.0 }}"
|
data-images="{{ image_url_array_and_count.0 }}"
|
||||||
data-auto-cache-stack=false>
|
data-auto-cache-stack=false>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
@@ -187,6 +187,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Detected Groups</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if detected_series_groups %}
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
{% for group in detected_series_groups %}
|
||||||
|
<div class="border rounded p-2">
|
||||||
|
<div class="fw-semibold small">{{ group.label|default:"Series group" }}</div>
|
||||||
|
<div class="small text-muted">{{ group.imageIds|length }} images</div>
|
||||||
|
{% if group.groupValues %}
|
||||||
|
<div class="small mt-1">
|
||||||
|
{% for key, value in group.groupValues.items %}
|
||||||
|
<span class="badge bg-secondary-subtle text-body border me-1 mb-1">{{ key }}={{ value }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-0">No detected grouping values for this series.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">Actions</div>
|
<div class="card-header">Actions</div>
|
||||||
<div class="card-body d-flex flex-column gap-2">
|
<div class="card-body d-flex flex-column gap-2">
|
||||||
@@ -268,7 +293,7 @@
|
|||||||
<div class="card-body p-2 d-flex flex-column gap-2">
|
<div class="card-body p-2 d-flex flex-column gap-2">
|
||||||
<div id="truncate_viewer" class="dicom-viewer-root w-100"
|
<div id="truncate_viewer" class="dicom-viewer-root w-100"
|
||||||
style="height: 460px; min-height: 360px; resize: vertical; overflow: auto; background: #222;"
|
style="height: 460px; min-height: 360px; resize: vertical; overflow: auto; background: #222;"
|
||||||
data-images="{{ image_url_array_and_count.0 }}"
|
data-images="{{ image_url_array_and_count.0 }}"
|
||||||
data-auto-cache-stack="false">
|
data-auto-cache-stack="false">
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted">Downsample compare preview loads into this viewer as stack options.</div>
|
<div class="small text-muted">Downsample compare preview loads into this viewer as stack options.</div>
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ from .models import (
|
|||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
PrerequisiteRequired
|
PrerequisiteRequired
|
||||||
, APIToken,
|
, APIToken,
|
||||||
|
_build_declared_series_groups,
|
||||||
)
|
)
|
||||||
from .models import Procedure
|
from .models import Procedure
|
||||||
from .models import NormalCase
|
from .models import NormalCase
|
||||||
@@ -2270,6 +2271,11 @@ def series_viewer(request, pk, finding_pk=None):
|
|||||||
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
|
||||||
def series_detail(request, pk, finding_pk=None):
|
def series_detail(request, pk, finding_pk=None):
|
||||||
series = get_object_or_404(Series, pk=pk)
|
series = get_object_or_404(Series, pk=pk)
|
||||||
|
series_images_with_urls = [
|
||||||
|
(image, f"{REMOTE_URL}{image.image.url}")
|
||||||
|
for image in series.get_images()
|
||||||
|
]
|
||||||
|
detected_series_groups = _build_declared_series_groups(series, series_images_with_urls)
|
||||||
|
|
||||||
can_edit = series.check_user_can_edit(request.user)
|
can_edit = series.check_user_can_edit(request.user)
|
||||||
|
|
||||||
@@ -2286,6 +2292,7 @@ def series_detail(request, pk, finding_pk=None):
|
|||||||
"series_finding_form": series_finding_form,
|
"series_finding_form": series_finding_form,
|
||||||
"editing_finding": editing_finding,
|
"editing_finding": editing_finding,
|
||||||
"can_edit": can_edit,
|
"can_edit": can_edit,
|
||||||
|
"detected_series_groups": detected_series_groups,
|
||||||
}
|
}
|
||||||
|
|
||||||
if can_edit:
|
if can_edit:
|
||||||
|
|||||||
Reference in New Issue
Block a user