improve findings

This commit is contained in:
Ross
2025-05-19 11:15:40 +01:00
parent ead5bfcace
commit 503c8f854a
13 changed files with 329 additions and 14 deletions
+38
View File
@@ -12,6 +12,10 @@ window.control_pressed = false;
$(document).ready(function () {
// Select all text input fields and textareas with the clear button
clearableTextAreaSetup();
document.body.addEventListener("htmx:responseError", function(e) {
let el = document.getElementById("htmx-error");
var error;
@@ -193,6 +197,40 @@ window.addEventListener('loadDicomViewerUrls', function (e) {
loadDicomViewer(e.detail.images, e.detail.annotations);
}, false);
function clearableTextAreaSetup() {
const textFields = document.querySelectorAll(".position-relative input[type='text'], .position-relative textarea");
textFields.forEach((field) => {
const wrapper = field.closest(".position-relative");
const clearButton = wrapper.querySelector(".clear-input-button");
// Function to toggle the visibility of the clear button
const toggleClearButton = () => {
if (field.value.trim() !== "" && wrapper.matches(":hover")) {
clearButton.style.display = "inline-block"; // Show the button
} else {
clearButton.style.display = "none"; // Hide the button
}
};
// Initial check
toggleClearButton();
// Add event listeners to monitor changes in the input field or textarea
field.addEventListener("input", toggleClearButton);
// Add hover event listeners to the wrapper
wrapper.addEventListener("mouseenter", toggleClearButton);
wrapper.addEventListener("mouseleave", toggleClearButton);
// Add click event to clear the field or textarea
clearButton.addEventListener("click", function () {
field.value = ""; // Clear the field
toggleClearButton();
});
});
}
function loadDicomViewer(images_to_load, annotations_to_load) {
console.log("loadDicomViewer", images_to_load);
let single_dicom = document.getElementById("single-dicom-viewer");
+18 -8
View File
@@ -49,7 +49,7 @@ from generic.models import Examination#, Sign
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.forms.widgets import RadioSelect, TextInput, Textarea, Select
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div
from crispy_forms.layout import Submit, Layout, Fieldset, Field, Div, HTML
from crispy_forms.bootstrap import Accordion, AccordionGroup
from tinymce.widgets import TinyMCE
@@ -71,6 +71,12 @@ from typing import Any
from helpers.cimar import CimarAPI, NotFoundError
from rad.settings import CIMAR_USERNAME, CIMAR_PASSWORD
class ClearableInput(TextInput):
template_name = "crispy_forms/widgets/clearable_input_single_line.html"
class ClearableTextarea(Textarea):
template_name = "crispy_forms/widgets/clearable_input.html"
class CaseSelect(Select):
template_name = "atlas/case_select_widget.html"
@@ -295,14 +301,16 @@ class SeriesForm(ModelForm):
class CaseForm(ModelForm):
# exams = ModelMultipleChoiceField(required=False, queryset=Exam.objects.all(),widget=FilteredSelectMultiple(verbose_name="Exams", is_stacked=False))
class Media:
# Django also includes a few javascript files necessary
# for the operation of this form element. You need to
# include <script src="/admin/jsi18n"></script>
# in the template.
#history = forms.CharField(
# widget=ClearableTextarea(attrs={"maxlength": 1000, "rows": 5, "cols": 80}),
# required=False,
#)
css = {
"all": ["css/widgets.css"],
}
@@ -367,7 +375,7 @@ class CaseForm(ModelForm):
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid"
"cimar_uuid",
),
)
@@ -410,9 +418,11 @@ class CaseForm(ModelForm):
# (False, 'No')])
# "findings": TinyMCE(attrs={"cols": 80, "rows": 20}),
# "mark_scheme": TinyMCE(attrs={"cols": 80, "rows": 30}),
"description": Textarea(attrs={"cols": 80, "rows": 5}),
"history": Textarea(attrs={"cols": 80, "rows": 5}),
"discussion": Textarea(attrs={"cols": 80, "rows": 5}),
"title": ClearableInput(attrs={"rows": 1}),
"description": ClearableTextarea(attrs={"cols": 80, "rows": 5}),
"history": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
"discussion": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
"report": ClearableTextarea(attrs={"maxlength": 2000, "rows": 5}),
"condition": autocomplete.ModelSelect2Multiple(
url="atlas:condition-autocomplete"
),
+15
View File
@@ -774,6 +774,21 @@ class Series(SeriesBase):
def get_link_headers(self):
return "atlas/series_headers.html"
def get_related_findings(self):
"""Returns the related findings for the series, including findings from other series in the same case."""
# Get findings directly related to this series
findings = list(self.findings.all())
# Get findings from other series in the same case
if hasattr(self, "case") and self.case.exists():
for case in self.case.all():
for other_series in case.series.exclude(pk=self.pk):
findings.extend(other_series.findings.all())
# Remove duplicates (by pk)
unique_findings = {f.pk: f for f in findings}.values()
return list(unique_findings)
class CaseCollection(ExamOrCollectionGenericBase):
@@ -214,8 +214,6 @@
>
<span>
<a href="{{series.get_absolute_url}}?show_finding={{finding.pk}}"><button class="btn btn-primary btn-sm">View</button></a>
<a target="_blank" href="{{series.get_absolute_url}}?show_finding={{finding.pk}}"><button class="btn btn-primary btn-sm">Popup</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>
+63 -1
View File
@@ -51,8 +51,70 @@
$('#add_more_resource').click(() => { add_resource_input_form() });
})
</script>
{% comment %} document.addEventListener("DOMContentLoaded", function () {
// Select all text input fields and textareas
const textFields = document.querySelectorAll("input[type='text'], textarea");
textFields.forEach((field) => {
// Create a clear button for each text field or textarea
const clearButton = document.createElement("button");
clearButton.type = "button";
clearButton.className = "btn btn-danger btn-sm clear-input-button";
clearButton.textContent = "×";
// Wrap the input field or textarea in a container with position-relative
const wrapper = document.createElement("div");
wrapper.className = "position-relative";
field.parentNode.insertBefore(wrapper, field);
wrapper.appendChild(field);
wrapper.appendChild(clearButton);
// Function to toggle the visibility of the clear button
const toggleClearButton = () => {
if (field.value.trim() !== "") {
clearButton.classList.add("show"); // Add the "show" class
} else {
clearButton.classList.remove("show"); // Remove the "show" class
}
};
// Initial check
toggleClearButton();
// Add event listeners to monitor changes in the input field or textarea
field.addEventListener("input", toggleClearButton);
// Add click event to clear the field or textarea
clearButton.addEventListener("click", function () {
field.value = ""; // Clear the field
toggleClearButton(); // Update the button visibility
});
});
});
{% endcomment %}
</script>
{% comment %} <style>
/* Ensure the parent container is positioned relatively */
.position-relative {
position: relative;
}
/* Style the clear button */
.clear-input-button {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: none; /* Hidden by default */
z-index: 1;
}
/* Show the clear button only when the input field or textarea is hovered and contains text */
.position-relative:hover .clear-input-button.show {
display: inline-block;
}
</style> {% endcomment %}
<!-- {{ form.media }} -->
{% endblock %}
{% block content %}
@@ -0,0 +1,124 @@
<div class="modal-dialog modal-dialog-centered related-finding-modal bg-dark">
<div class="modal-content bg-dark text-light border-secondary">
<div class="modal-content bg-dark text-light">
<h5 class="modal-title">Related findings from case</h5>
</div>
<div class="modal-body">
<details class="help-text">
<summary><i class="bi bi-info-circle"></i> Help</summary>
<p>This form is designed to quickly populate the same finding across different series.</p>
<p>Cloning a finding from this form will populate the details with a blank viewport (you will need to add visual annotation and set the display as intended).</p>
</details>
<ul class="list-group list-group-flush bg-dark">
{% for finding in findings %}
<li class="list-group-item bg-dark text-light border-secondary">
<div class="fw-bold mb-1 text-info">{{ finding.title }}</div>
{% if finding.description %}
Description:
<div class="text-muted small mb-1" style="color:#b0b0b0 !important;">{{ finding.description|truncatechars:100 }}</div>
{% endif %}
{% if finding.findings.all %}
<div class="mb-1">
<span class="fw-semibold text-warning">Findings:</span>
{% for subfinding in finding.findings.all %}
{{ subfinding.get_link }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if finding.structures.all %}
<div class="mb-1">
<span class="fw-semibold text-warning">Structures:</span>
{% for structure in finding.structures.all %}
{{ structure.get_link }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% if finding.conditions.all %}
<div class="mb-1">
<span class="fw-semibold text-warning">Conditions:</span>
{% for condition in finding.conditions.all %}
{{ condition.get_link }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
<div class="mt-2">
<button
class="btn btn-sm btn-primary text-light use-finding-base-btn"
type="button"
data-finding-id="{{ finding.pk }}"
data-finding-description="{{ finding.description|default_if_none:''|escapejs }}"
{% if finding.conditions.all %}
data-finding-conditions="{% for c in finding.conditions.all %}{{ c.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
data-finding-conditions-names="{% for c in finding.conditions.all %}{{ c }}{% if not forloop.last %}|{% endif %}{% endfor %}"
{% endif %}
{% if finding.structures.all %}
data-finding-structures="{% for s in finding.structures.all %}{{ s.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
data-finding-structures-names="{% for s in finding.structures.all %}{{ s }}{% if not forloop.last %}|{% endif %}{% endfor %}"
{% endif %}
{% if finding.findings.all %}
data-finding-findings="{% for f in finding.findings.all %}{{ f.pk }}{% if not forloop.last %},{% endif %}{% endfor %}"
data-finding-findings-names="{% for f in finding.findings.all %}{{ f }}{% if not forloop.last %}|{% endif %}{% endfor %}"
{% endif %}
>Use as finding base</button>
</div>
</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
<script>
document.querySelectorAll('.use-finding-base-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
console.log('Button clicked:', btn);
// Example: populate #hidden-form fields with finding data
const form = window.parent.document.querySelector('#hidden-form');
if (!form) return;
form.style.display = 'block'; // Show the form if hidden
document.querySelector('#add-finding-button').style.display = 'none'; // Hide the button
// Set values (adjust field names as needed)
form.querySelector('[name="description"]').value = btn.getAttribute('data-finding-description');
function setSelect2Values(fieldName, dataAttr, namesAttr) {
const values = btn.getAttribute(dataAttr);
const names = btn.getAttribute(namesAttr);
const select = form.querySelector(`[name="${fieldName}"]`);
if (values && select) {
const valueArr = values.split(',').map(v => v.trim()).filter(Boolean);
const nameArr = names ? names.split('|') : [];
valueArr.forEach((val, idx) => {
if (val && !Array.from(select.options).some(opt => opt.value == val)) {
const opt = document.createElement('option');
opt.value = val;
opt.text = nameArr[idx] || val;
opt.selected = true;
select.appendChild(opt);
}
});
$(select).val(valueArr).trigger('change');
}
}
// Usage inside your click handler:
setSelect2Values('conditions', 'data-finding-conditions', 'data-finding-conditions-names');
setSelect2Values('structures', 'data-finding-structures', 'data-finding-structures-names');
setSelect2Values('findings', 'data-finding-findings', 'data-finding-findings-names');
// Optionally, close the modal (if using Bootstrap modal JS)
const modal = window.parent.document.querySelector('#clone-findings-modal');
if (modal) {
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) modalInstance.hide();
}
});
});
</script>
+17
View File
@@ -47,6 +47,13 @@
{% if editing_finding < 1 %}
<button id="add-finding-button">Add finding</button>
{% endif %}
<button id="clone-finding-button" title="Click to copy the details of a finding that is already associated with the series/case"
hx-get="{% url 'atlas:series_finding_related' series.pk %}"
hx-target="#clone-findings-modal"
hx-trigger="click"
data-bs-toggle="modal"
data-bs-target="#clone-findings-modal"
>Clone existing finding</button>
<button id="reset-viewport-button">Reset viewport</button>
<div id="finding-form">
<div class="hide" id="hidden-form">
@@ -182,6 +189,16 @@
{% endif %}
<div id="clone-findings-modal"
class="modal modal-blur fade"
style="display: none"
aria-hidden="false"
tabindex="-1">
<div class="modal-dialog modal-dialog-centered related-finding-modal" style="max-width: 700px;">
<div class="modal-content"></div>
</div>
</div>
<script>
$(document).ready(function () {
+1
View File
@@ -324,6 +324,7 @@ urlpatterns = [
# TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
path("series/", views.SeriesView.as_view(), name="series_view"),
path("series_image/<int:pk>/dicom", views.series_image_dicom, name="series_image_dicom"),
path(
+9 -3
View File
@@ -1148,7 +1148,7 @@ def add_collection_to_case_form(request, case_id):
class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
"""Clones a existing atlas"""
"""Clones a existing atlas case"""
# fields = '__all__'
# #fields = [ 'condition' ]
@@ -1169,7 +1169,7 @@ class AtlasClone(AtlasCreateBase, AuthorOrCheckerRequiredMixin, CreateView):
else:
pass
initial_data = model_to_dict(old_object, exclude=["id", "previous_case"])
initial_data = model_to_dict(old_object, exclude=["id", "previous_case", "history", "discussion", "report"])
return initial_data
@@ -3139,4 +3139,10 @@ def remove_series_from_case(request, series_pk, case_pk):
@login_required
def series_finding_details(request, finding_id):
finding = get_object_or_404(SeriesFinding, pk=finding_id)
return render(request, 'atlas/series_finding_details.html', {'finding': finding})
return render(request, 'atlas/series_finding_details.html', {'finding': finding})
@login_required
def series_finding_related(request, series_id):
series = get_object_or_404(Series, pk=series_id)
findings = series.get_related_findings()
return render(request, 'atlas/series_finding_related.html', {'series': series, 'findings': findings})
@@ -0,0 +1,4 @@
<div class="position-relative">
{% include "django/forms/widgets/textarea.html" %}
<button type="button" class="btn btn-danger btn-sm clear-input-button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none;">×</button>
</div>
@@ -0,0 +1,4 @@
<div class="position-relative">
{% include "django/forms/widgets/input.html" %}
<button type="button" class="btn btn-danger btn-sm clear-input-button" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); display: none;">×</button>
</div>
+4
View File
@@ -307,6 +307,10 @@ ADMINS = [("Ross","ross@xkjq.uk")]
CRISPY_ALLOWED_TEMPLATE_PACKS = ('bootstrap5',)
CRISPY_TEMPLATE_PACK = 'bootstrap5'
CRISPY_CLASS_CONVERTERS = {
"textinput": "clearable_input", # Use clearable_input.html for TextInput
"textarea": "clearable_input", # Use clearable_input.html for Textarea
}
DEBUG_CONTAINER = False
+32
View File
@@ -1356,3 +1356,35 @@ span#user-id {
.remove-button:hover {
opacity: 100%;
}
/* Crispy forms clear button */
.position-relative {
position: relative;
}
/* Style the clear button */
.clear-input-button {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: none; /* Hidden by default */
z-index: 1;
opacity: 0.4;
}
.clear-input-button:hover {
opacity: 1;
}
/* Show the clear button only when the input field or textarea is hovered and contains text */
.position-relative:hover .clear-input-button {
display: inline-block;
}
.related-finding-modal {
max-width: 600px;
width: auto;
margin-left: auto;
margin-right: auto;
}