Implement resource search functionality and enhance resource management in case details

This commit is contained in:
Ross
2025-12-15 09:53:19 +00:00
parent 1e2300bd70
commit 4bec329aff
7 changed files with 321 additions and 130 deletions
+39 -9
View File
@@ -201,15 +201,45 @@
</div> </div>
</div> </div>
</div> </div>
<p class="pre-whitespace"><b>Resource</b><br/>{% for caseresource in case.caseresource_set.all %} <div class="mb-3">
<a href='{% url "atlas:resource_detail" caseresource.resource.pk %}'>{{caseresource.resource.name}}</a> <h5>Resources</h5>
{% if caseresource.pre_review %}
<span title="This resource will be avalible to review when or taking a case">(pre)</span> <div class="mb-2">
{% else %} <form id="resource-search-form" class="row g-2" hx-get="{% url 'atlas:resource_search_widget' %}" hx-target="#resource-search-results" hx-swap="innerHTML" hx-trigger="keyup changed delay:500ms from:#resource-search-q">
<span title="This resource will only be available when reviewing a completed case">(post)</span> <input type="hidden" name="case_id" value="{{ case.pk }}" />
{% endif %} <div class="col-auto" style="flex: 1 1 300px;">
{% endfor %} <input type="search" id="resource-search-q" name="q" class="form-control form-control-sm" placeholder="Search resources by name, description or URL" />
</p> </div>
<div class="col-auto">
<select name="site" class="form-select form-select-sm">
<option value="">All sites</option>
{% for s in site_queryset %}
<option value="{{ s.pk }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="subspecialty" class="form-select form-select-sm">
<option value="">All subspecialties</option>
{% for sp in subspecialty_queryset %}
<option value="{{ sp.pk }}">{{ sp.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="resource-open-access-{{ case.pk }}" name="open_access" value="1">
<label class="form-check-label small" for="resource-open-access-{{ case.pk }}">Open</label>
</div>
</div>
</form>
<div id="resource-search-results" class="mt-2"></div>
</div>
<div>
{% include 'atlas/partials/case_resources_list.html' with case=case %}
</div>
</div>
<div class="pre-whitespace multi-image-block"><b>Series:</b> <div class="pre-whitespace multi-image-block"><b>Series:</b>
<form> <form>
@@ -0,0 +1,19 @@
<div id="case-resources-list">
{% if case.caseresource_set.exists %}
<ul class="list-unstyled mb-0">
{% for cr in case.caseresource_set.all %}
<li class="py-1 d-flex justify-content-between align-items-center" data-caseresource-id="{{ cr.id }}">
<div>
<a href="{% url 'atlas:resource_detail' cr.resource.pk %}">{{ cr.resource.name }}</a>
{% if cr.pre_review %}<span class="badge bg-info text-dark ms-2 small">pre</span>{% endif %}
</div>
<div>
<button type="button" class="btn btn-sm btn-outline-danger" hx-post="{% url 'atlas:case_detach_resource' pk=case.pk %}" hx-vals='{"resource_id": "{{ cr.resource.pk }}"}' hx-swap="outerHTML" hx-target="#case-resources-list">Remove</button>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-muted">No resources attached to this case.</div>
{% endif %}
</div>
+119 -119
View File
@@ -5,142 +5,142 @@
</div> </div>
<script> <script>
(function(){ (function(){
var wrapperId = 'resource_media_wrapper_{{ resource.pk }}'; var wrapperId = 'resource_media_wrapper_{{ resource.pk }}';
if (document.getElementById(wrapperId).dataset._inited) return; if (document.getElementById(wrapperId).dataset._inited) return;
var wrap = document.getElementById(wrapperId); var wrap = document.getElementById(wrapperId);
if (!wrap) return; if (!wrap) return;
wrap.dataset._inited = '1'; wrap.dataset._inited = '1';
var src = wrap.getAttribute('data-src'); var src = wrap.getAttribute('data-src');
if (!src) return; if (!src) return;
var lower = src.toLowerCase(); var lower = src.toLowerCase();
function createImg(src) { function createImg(src) {
var img = document.createElement('img'); var img = document.createElement('img');
img.src = src; img.src = src;
img.className = 'img-fluid resource-media-element rounded'; img.className = 'img-fluid resource-media-element rounded';
img.style.maxHeight = '60vh'; img.style.maxHeight = '60vh';
img.style.objectFit = 'contain'; img.style.objectFit = 'contain';
img.style.cursor = 'zoom-in'; img.style.cursor = 'zoom-in';
return img; return img;
} }
function createVideo(src) { function createVideo(src) {
var video = document.createElement('video'); var video = document.createElement('video');
video.className = 'resource-media-element rounded'; video.className = 'resource-media-element rounded';
video.controls = true; video.controls = true;
video.preload = 'metadata'; video.preload = 'metadata';
video.style.maxWidth = '100%'; video.style.maxWidth = '100%';
video.style.maxHeight = '60vh'; video.style.maxHeight = '60vh';
video.style.objectFit = 'contain'; video.style.objectFit = 'contain';
var srcEl = document.createElement('source'); var srcEl = document.createElement('source');
srcEl.src = src; srcEl.src = src;
video.appendChild(srcEl); video.appendChild(srcEl);
return video; return video;
} }
function createIframe(src, embedUrl) { function createIframe(src, embedUrl) {
var iframe = document.createElement('iframe'); var iframe = document.createElement('iframe');
iframe.src = embedUrl || src; iframe.src = embedUrl || src;
iframe.setAttribute('frameborder','0'); iframe.setAttribute('frameborder','0');
iframe.setAttribute('allowfullscreen',''); iframe.setAttribute('allowfullscreen','');
iframe.className = 'resource-media-element rounded'; iframe.className = 'resource-media-element rounded';
iframe.style.width = '100%'; iframe.style.width = '100%';
iframe.style.height = '60vh'; iframe.style.height = '60vh';
return iframe; return iframe;
} }
var el = null; var el = null;
// YouTube // YouTube
if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) { if (lower.indexOf('youtube.com') !== -1 || lower.indexOf('youtu.be') !== -1) {
// build embed url // build embed url
var videoId = null; var videoId = null;
try { try {
var m = src.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/); var m = src.match(/(?:v=|v\/|embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/);
if (m && m[1]) videoId = m[1]; if (m && m[1]) videoId = m[1];
} catch(e){} } catch(e){}
var embed = videoId ? ('https://www.youtube.com/embed/' + videoId) : src; var embed = videoId ? ('https://www.youtube.com/embed/' + videoId) : src;
el = createIframe(src, embed); el = createIframe(src, embed);
} else if (lower.indexOf('vimeo.com') !== -1) { } else if (lower.indexOf('vimeo.com') !== -1) {
var m2 = src.match(/vimeo\.com\/(\d+)/); var m2 = src.match(/vimeo\.com\/(\d+)/);
var embed2 = m2 && m2[1] ? ('https://player.vimeo.com/video/' + m2[1]) : src; var embed2 = m2 && m2[1] ? ('https://player.vimeo.com/video/' + m2[1]) : src;
el = createIframe(src, embed2); el = createIframe(src, embed2);
} else if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) { } else if (lower.match(/\.(mp4|webm|ogg)(?:$|\?|#)/)) {
el = createVideo(src); el = createVideo(src);
} else if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) { } else if (lower.match(/\.(jpe?g|png|gif|webp|svg)(?:$|\?|#)/)) {
el = createImg(src); el = createImg(src);
} else { } else {
// unknown: try image first, fallback to link // unknown: try image first, fallback to link
el = createImg(src); el = createImg(src);
// if image fails to load, replace with link // if image fails to load, replace with link
el.addEventListener('error', function(){ el.addEventListener('error', function(){
var a = document.createElement('a'); var a = document.createElement('a');
a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource'; a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource';
wrap.innerHTML = ''; wrap.appendChild(a); wrap.innerHTML = ''; wrap.appendChild(a);
}); });
} }
// append preview element // append preview element
if (el) { if (el) {
wrap.appendChild(el); wrap.appendChild(el);
// click -> open modal/full-size // click -> open modal/full-size
el.addEventListener('click', function(e) { el.addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
// create modal container if not present // create modal container if not present
var modalId = 'resource_media_modal_{{ resource.pk }}'; var modalId = 'resource_media_modal_{{ resource.pk }}';
var existing = document.getElementById(modalId); var existing = document.getElementById(modalId);
if (!existing) { if (!existing) {
var modalDiv = document.createElement('div'); var modalDiv = document.createElement('div');
modalDiv.className = 'modal fade resource-media-modal'; modalDiv.className = 'modal fade resource-media-modal';
modalDiv.id = modalId; modalDiv.id = modalId;
modalDiv.tabIndex = -1; modalDiv.tabIndex = -1;
modalDiv.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content bg-dark"><div class="modal-body p-0 text-center"></div></div></div>'; modalDiv.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-centered"><div class="modal-content bg-dark"><div class="modal-body p-0 text-center"></div></div></div>';
document.body.appendChild(modalDiv); document.body.appendChild(modalDiv);
} }
var modalEl = document.getElementById(modalId); var modalEl = document.getElementById(modalId);
var body = modalEl.querySelector('.modal-body'); var body = modalEl.querySelector('.modal-body');
body.innerHTML = ''; body.innerHTML = '';
// create full-size node depending on type // create full-size node depending on type
if (el.tagName.toLowerCase() === 'img') { if (el.tagName.toLowerCase() === 'img') {
var big = document.createElement('img'); var big = document.createElement('img');
big.src = src; big.src = src;
big.style.maxWidth = '95vw'; big.style.maxWidth = '95vw';
big.style.maxHeight = '95vh'; big.style.maxHeight = '95vh';
big.style.objectFit = 'contain'; big.style.objectFit = 'contain';
big.className = 'img-fluid'; big.className = 'img-fluid';
body.appendChild(big); body.appendChild(big);
} else if (el.tagName.toLowerCase() === 'video') { } else if (el.tagName.toLowerCase() === 'video') {
var bigv = document.createElement('video'); var bigv = document.createElement('video');
bigv.controls = true; bigv.autoplay = false; bigv.style.maxWidth = '95vw'; bigv.style.maxHeight = '95vh'; bigv.style.objectFit = 'contain'; bigv.className = 'w-100'; bigv.controls = true; bigv.autoplay = false; bigv.style.maxWidth = '95vw'; bigv.style.maxHeight = '95vh'; bigv.style.objectFit = 'contain'; bigv.className = 'w-100';
var s = document.createElement('source'); s.src = src; bigv.appendChild(s); var s = document.createElement('source'); s.src = src; bigv.appendChild(s);
body.appendChild(bigv); body.appendChild(bigv);
} else if (el.tagName.toLowerCase() === 'iframe') { } else if (el.tagName.toLowerCase() === 'iframe') {
var frame = document.createElement('iframe'); var frame = document.createElement('iframe');
frame.src = el.src; frame.src = el.src;
frame.setAttribute('frameborder','0'); frame.setAttribute('frameborder','0');
frame.setAttribute('allowfullscreen',''); frame.setAttribute('allowfullscreen','');
frame.style.width = '95vw'; frame.style.height = '80vh'; frame.style.width = '95vw'; frame.style.height = '80vh';
body.appendChild(frame); body.appendChild(frame);
} else { } else {
var a = document.createElement('a'); a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource'; body.appendChild(a); var a = document.createElement('a'); a.href = src; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Open resource'; body.appendChild(a);
} }
// show modal using Bootstrap if available // show modal using Bootstrap if available
try { try {
var bsModal = new bootstrap.Modal(modalEl); var bsModal = new bootstrap.Modal(modalEl);
bsModal.show(); bsModal.show();
} catch (e) { } catch (e) {
// fallback: open in new tab // fallback: open in new tab
window.open(src, '_blank', 'noopener'); window.open(src, '_blank', 'noopener');
} }
}); });
} }
})(); })();
</script> </script>
<style> <style>
.resource-media-wrapper .resource-media-element { display: inline-block; } .resource-media-wrapper .resource-media-element { display: inline-block; }
.resource-media-wrapper img.resource-media-element { max-width: 100%; height: auto; } .resource-media-wrapper img.resource-media-element { max-width: 100%; height: auto; }
.resource-media-wrapper video.resource-media-element { max-width: 100%; height: auto; } .resource-media-wrapper video.resource-media-element { max-width: 100%; height: auto; }
.resource-media-modal .modal-content { background: rgba(0,0,0,0.95); } .resource-media-modal .modal-content { background: rgba(0,0,0,0.95); }
</style> </style>
@@ -0,0 +1,37 @@
{% if results %}
<ul class="list-unstyled mb-0" id="resource_search_results_list_{{ field }}">
{% for item in results %}
<li class="py-1 d-flex justify-content-between align-items-center" data-resource-id="{{ item.id }}">
<div class="flex-grow-1">
<a href="{% url 'atlas:resource_detail' item.pk %}" target="_blank">{{ item.name|escape }}</a>
<div class="small text-muted">{{ item.get_display|safe }}</div>
</div>
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-sm btn-success resource-attach-btn"
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "0"}'
hx-swap="outerHTML"
hx-target="#case-resources-list">
Attach
</button>
<button
type="button"
class="btn btn-sm btn-outline-success resource-attach-pre-btn"
hx-post="{% url 'atlas:case_attach_resource' pk=case_id %}"
hx-vals='{"resource_id": "{{ item.id }}", "pre_review": "1"}'
hx-swap="outerHTML"
hx-target="#case-resources-list">
Attach (pre)
</button>
</div>
</li>
{% endfor %}
</ul>
<div class="mt-2 text-end">
<small class="text-muted">{{ results|length }} results</small>
</div>
{% else %}
<div><em>No resources found</em></div>
{% endif %}
+1 -1
View File
@@ -45,7 +45,7 @@
<dd class="col-sm-9"> <dd class="col-sm-9">
<a href="{{ resource.file.url }}" target="_blank" rel="noopener">Download</a> <a href="{{ resource.file.url }}" target="_blank" rel="noopener">Download</a>
{% if resource.file.name %} {% if resource.file.name %}
<small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small> <small class="text-muted ms-2">({{ resource.file.name|truncatechars:40 }})</small>
{% endif %} {% endif %}
</dd> </dd>
{% endif %} {% endif %}
+6
View File
@@ -49,6 +49,8 @@ urlpatterns = [
path("resource/<int:pk>/view", views.resource_view, name="resource_view"), path("resource/<int:pk>/view", views.resource_view, name="resource_view"),
path("resource/<int:pk>/update", views.ResourceUpdate.as_view(), name="resource_update"), path("resource/<int:pk>/update", views.ResourceUpdate.as_view(), name="resource_update"),
path("resource/<int:pk>/delete", views.ResourceDelete.as_view(), name="resource_delete"), path("resource/<int:pk>/delete", views.ResourceDelete.as_view(), name="resource_delete"),
path("resource-search-widget/", views.resource_search_widget, name="resource_search_widget"),
path( path(
"resource/create", "resource/create",
views.ResourceCreate.as_view(), views.ResourceCreate.as_view(),
@@ -177,6 +179,10 @@ urlpatterns = [
views.collection_case_details, views.collection_case_details,
name="collection_case_details", name="collection_case_details",
), ),
# HTMX endpoints for attaching/detaching resources to a case
path("case/<int:pk>/attach-resource/", views.case_attach_resource, name="case_attach_resource"),
path("case/<int:pk>/detach-resource/", views.case_detach_resource, name="case_detach_resource"),
# Backwards-compatible redirect from legacy case_id-style URLs # Backwards-compatible redirect from legacy case_id-style URLs
path( path(
"collection/<int:exam_id>/case/id/<int:case_id>/details", "collection/<int:exam_id>/case/id/<int:case_id>/details",
+100 -1
View File
@@ -46,7 +46,7 @@ from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from generic.models import CidUser, CidUserExam, CimarCase from generic.models import CidUser, CidUserExam, CimarCase, Site
from atlas.helpers import get_cases_available_to_user from atlas.helpers import get_cases_available_to_user
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
@@ -478,6 +478,9 @@ def case_detail(request, pk):
"is_atlas_editor": is_atlas_editor, "is_atlas_editor": is_atlas_editor,
"is_case_author": is_case_author, "is_case_author": is_case_author,
"can_mark_normal": can_mark_normal, "can_mark_normal": can_mark_normal,
# Provide helper lists for resource search filters
"site_queryset": Site.objects.all(),
"subspecialty_queryset": Subspecialty.objects.all(),
}, },
) )
@@ -4007,6 +4010,102 @@ def case_dicom_json(request, pk):
return JsonResponse(case.get_case_dicom_json()) return JsonResponse(case.get_case_dicom_json())
@login_required
def resource_search_widget(request):
"""HTMX endpoint: search existing `Resource` objects with simple filters.
GET params:
- q: query string (name/description)
- site: site id (optional)
- subspecialty: subspecialty id (optional)
- open_access: '1' or '0' (optional)
Returns rendered partial `atlas/partials/resource_search_widget_results.html`.
"""
from .models import Resource
q = request.GET.get("q", "").strip()
site = request.GET.get("site")
subspecialty = request.GET.get("subspecialty")
open_access = request.GET.get("open_access")
qs = Resource.objects.all()
if q:
qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q) | Q(url__icontains=q))
if site:
try:
qs = qs.filter(sites__id=int(site))
except Exception:
pass
if subspecialty:
try:
qs = qs.filter(subspecialty__id=int(subspecialty))
except Exception:
pass
if open_access in ["0", "1"]:
qs = qs.filter(open_access=(open_access == "1"))
# Restrict to what the user may see: open_access or authored (unless editor)
if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
qs = qs.filter(Q(open_access=True) | Q(author__id=request.user.id)).distinct()
results = qs.order_by("name")[:50]
field = request.GET.get("field", "resource_search")
case_id = request.GET.get("case_id")
return render(request, "atlas/partials/resource_search_widget_results.html", {"results": results, "field": field, "case_id": case_id})
@login_required
@require_POST
def case_attach_resource(request, pk):
"""Attach an existing resource to the case (HTMX POST).
POST params:
- resource_id: integer
- pre_review: '1' or '0' (optional)
Returns the updated `atlas/partials/case_resources_list.html` fragment.
"""
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
pre_review = request.POST.get("pre_review", "0") == "1"
from .models import Resource, CaseResource
try:
resource = Resource.objects.get(pk=int(resource_id))
except Exception:
return HttpResponse(status=400)
# create if not exists
cr, created = CaseResource.objects.get_or_create(case=case, resource=resource, defaults={"pre_review": pre_review})
if not created:
# update flag
cr.pre_review = pre_review
cr.save()
# Render updated list
return render(request, "atlas/partials/case_resources_list.html", {"case": case})
@login_required
@require_POST
def case_detach_resource(request, pk):
case = get_case_for_case_detail(pk)
if not case.check_user_can_edit(request.user):
return HttpResponse(status=403)
resource_id = request.POST.get("resource_id")
from .models import CaseResource
try:
cr = CaseResource.objects.filter(case=case, resource__id=int(resource_id))
cr.delete()
except Exception:
pass
return render(request, "atlas/partials/case_resources_list.html", {"case": case})
def series_dicom_json(request, pk): def series_dicom_json(request, pk):
series = get_object_or_404(Series, pk=pk) series = get_object_or_404(Series, pk=pk)